inventory

package
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 25, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package inventory turns raw handshake observations into reportable records: named parameters, a post-quantum readiness verdict, and findings.

This is where policy lives. tlsparse deliberately says only what was on the wire; deciding that 3DES is worth flagging, or that a TLS 1.2 flow is noteworthy, is a judgement that changes over time and must not be welded into the parser.

Index

Constants

View Source
const (
	FindingObsoleteProtocol   = "obsolete_protocol"
	FindingLegacyProtocol     = "legacy_protocol"
	FindingBrokenCipher       = "broken_cipher"
	FindingWeakCipher         = "weak_cipher"
	FindingNoForwardSecrecy   = "no_forward_secrecy"
	FindingAnonymousKeyExch   = "anonymous_key_exchange"
	FindingExportCipher       = "export_cipher"
	FindingNullEncryption     = "null_encryption"
	FindingSHA1Signature      = "sha1_signature"
	FindingCompressionOffered = "compression_offered"
	FindingClassicalKeyExch   = "classical_key_exchange"
	FindingECHOffered         = "ech_offered"
	FindingWeakCertKey        = "weak_certificate_key"
	FindingCertExpired        = "certificate_expired"
	FindingCertSHA1           = "certificate_sha1_signature"
	FindingUnknownCipher      = "unknown_cipher"
)

Finding identifiers. Stable strings: reports, filters and any downstream tooling key off these, so they are part of the output contract.

Variables

This section is empty.

Functions

This section is empty.

Types

type Accumulator

type Accumulator struct {
	// contains filtered or unexported fields
}

Accumulator builds a Summary incrementally.

func NewAccumulator

func NewAccumulator() *Accumulator

NewAccumulator returns an empty Accumulator.

func (*Accumulator) Add

func (a *Accumulator) Add(r *Record)

Add folds one record into the summary.

func (*Accumulator) Aggregates

func (a *Accumulator) Aggregates(topN int) []Aggregate

Aggregates returns the distinct findings, worst first. topN bounds the result; pass 0 for all.

func (*Accumulator) AggregatesDropped

func (a *Accumulator) AggregatesDropped() int

AggregatesDropped is how many distinct findings were discarded because maxAggregates was reached. Non-zero means the inventory is incomplete.

func (*Accumulator) DistinctFindings

func (a *Accumulator) DistinctFindings() int

DistinctFindings is the number of aggregates retained.

func (*Accumulator) Summary

func (a *Accumulator) Summary(topN int) Summary

Summary finalises and returns the roll-up. topN bounds each distribution; pass 0 for no limit.

type Aggregate

type Aggregate struct {
	Transport  string     `json:"transport"`
	ServerName string     `json:"server_name,omitempty"`
	ServerIP   netip.Addr `json:"server_ip"`
	ServerPort uint16     `json:"server_port"`

	Version     string `json:"version,omitempty"`
	CipherSuite string `json:"cipher_suite,omitempty"`
	Group       string `json:"group,omitempty"`
	ALPN        string `json:"alpn,omitempty"`
	// JA4s are the distinct client fingerprints seen for this finding,
	// capped at maxTrackedClients.
	JA4s []string `json:"ja4s,omitempty"`
	PQ   PQStatus `json:"pq_status"`
	ECH  bool     `json:"ech,omitempty"`
	// ECHConfigIDs is how many distinct config_ids were seen for this
	// destination. More than one, across more than one connection, means
	// the extension was GREASE: a real ECH config has a stable id.
	ECHConfigIDs int `json:"ech_config_ids,omitempty"`
	// ECHLikelyGREASE is that inference. When true the server name is a
	// genuine destination despite the extension being present.
	ECHLikelyGREASE bool `json:"ech_likely_grease,omitempty"`

	ServerObserved bool `json:"server_observed"`

	Count int `json:"count"`
	// Clients is the number of distinct client addresses seen, capped at
	// maxTrackedClients; ClientsCapped says the true number is higher.
	Clients       int  `json:"clients"`
	ClientsCapped bool `json:"clients_capped,omitempty"`

	FirstSeen time.Time `json:"first_seen"`
	LastSeen  time.Time `json:"last_seen"`

	Severity Severity `json:"severity"`
	Findings []string `json:"findings,omitempty"`
}

Aggregate is one distinct finding: a destination and the cryptography negotiated with it, however many times that happened.

type CertInfo

type CertInfo struct {
	Subject            string    `json:"subject"`
	Issuer             string    `json:"issuer"`
	NotAfter           time.Time `json:"not_after"`
	PublicKeyAlgorithm string    `json:"public_key_algorithm"`
	KeyBits            int       `json:"key_bits,omitempty"`
	SignatureAlgorithm string    `json:"signature_algorithm"`
	SelfSigned         bool      `json:"self_signed,omitempty"`
}

CertInfo summarises one certificate. Available for TLS 1.2 and below only: TLS 1.3 encrypts the Certificate message, so on a modern handshake there is nothing to report and this stays empty.

type Count

type Count struct {
	Name  string `json:"name"`
	Count int    `json:"count"`
}

Count is one row of a distribution.

type Finding

type Finding struct {
	ID       string   `json:"id"`
	Severity Severity `json:"severity"`
	Detail   string   `json:"detail"`
}

Finding is one thing worth reporting about a handshake.

type FindingCount

type FindingCount struct {
	ID       string   `json:"id"`
	Severity Severity `json:"severity"`
	Count    int      `json:"count"`
	Example  string   `json:"example,omitempty"`
}

FindingCount aggregates one finding ID across the run.

type PQStatus

type PQStatus string

PQStatus is the post-quantum readiness of a single handshake.

The distinction between the middle states is the point of the whole exercise. A client that lists X25519MLKEM768 in supported_groups but sends no key share for it will complete a fully classical handshake against any server that accepts the offer. Counting it as "post-quantum ready" is the most common way a migration dashboard flatters itself.

const (
	// PQNegotiated: the server selected a post-quantum group. Actually done.
	PQNegotiated PQStatus = "post_quantum"
	// PQOffered: the client sent a post-quantum key share and the server
	// chose classical anyway. The client is ready; the server is not.
	PQOffered PQStatus = "offered_not_selected"
	// PQAdvertised: post-quantum appears only in supported_groups, with no
	// key share. Nothing post-quantum will happen without a retry.
	PQAdvertised PQStatus = "advertised_only"
	// PQClassical: no post-quantum group anywhere in the handshake.
	PQClassical PQStatus = "classical"
	// PQUnknown: not enough of the handshake was captured to say.
	PQUnknown PQStatus = "unknown"
)

type Record

type Record struct {
	FirstSeen time.Time `json:"first_seen"`
	LastSeen  time.Time `json:"last_seen"`

	// Transport is "tcp" or "quic". Worth reporting because it changes
	// what could have been seen: over QUIC the certificate is encrypted at
	// the Handshake level, so an empty certificate list means "not
	// visible", not "none presented".
	Transport string `json:"transport"`

	ClientIP   netip.Addr `json:"client_ip"`
	ClientPort uint16     `json:"client_port"`
	ServerIP   netip.Addr `json:"server_ip"`
	ServerPort uint16     `json:"server_port"`

	ServerName string `json:"server_name,omitempty"`
	// ECH reports that an encrypted_client_hello extension was present.
	//
	// It does not mean ServerName is unreliable. Chrome sends a decoy ECH
	// extension on connections where no ECH config exists — GREASE,
	// deliberately shaped to be indistinguishable from the real thing so
	// middleboxes cannot learn to reject it. A passive observer cannot tell
	// the two apart from the extension alone; that is its design goal.
	//
	// In practice most of what carries this extension is GREASE and the
	// server name is genuine, so treating presence as proof discards real
	// hostnames on a false premise.
	ECH bool `json:"ech,omitempty"`

	// ECHConfigID is the config_id from the extension. Real ECH reuses the
	// id published in DNS, so repeated connections to one host carry the
	// same value; GREASE randomises it. Variation across connections is
	// therefore evidence of GREASE — the only discriminator available
	// without an active DNS lookup, and only in aggregate.
	ECHConfigID uint8 `json:"ech_config_id,omitempty"`

	VersionOffered string `json:"version_offered"`
	Version        string `json:"version,omitempty"`

	CipherSuite         string   `json:"cipher_suite,omitempty"`
	CipherSuitesOffered []string `json:"cipher_suites_offered"`
	ForwardSecrecy      *bool    `json:"forward_secrecy,omitempty"`

	Group           string   `json:"group,omitempty"`
	GroupSource     string   `json:"group_source,omitempty"`
	KeyShareGroups  []string `json:"key_share_groups,omitempty"`
	SupportedGroups []string `json:"supported_groups,omitempty"`

	SignatureAlgorithms []string `json:"signature_algorithms,omitempty"`
	ALPNOffered         []string `json:"alpn_offered,omitempty"`
	ALPN                string   `json:"alpn,omitempty"`

	JA4  string `json:"ja4,omitempty"`
	JA4S string `json:"ja4s,omitempty"`

	PQ           PQStatus   `json:"pq_status"`
	Findings     []Finding  `json:"findings,omitempty"`
	Certificates []CertInfo `json:"certificates,omitempty"`

	// ServerObserved is false when only the client side was captured. Such
	// a record still reports what was offered, but nothing negotiated.
	ServerObserved bool `json:"server_observed"`
	Truncated      bool `json:"truncated,omitempty"`
}

Record is one handshake, named and judged.

func Analyze

func Analyze(f *assemble.Flow) *Record

Analyze converts an observed flow into a record.

func (*Record) MaxSeverity

func (r *Record) MaxSeverity() Severity

MaxSeverity returns the highest severity among the findings.

type Severity

type Severity string

Severity ranks a finding.

const (
	SevCritical Severity = "critical"
	SevHigh     Severity = "high"
	SevMedium   Severity = "medium"
	SevLow      Severity = "low"
	SevInfo     Severity = "info"
)

type Summary

type Summary struct {
	Flows          int       `json:"flows"`
	ServerObserved int       `json:"server_observed"`
	FirstSeen      time.Time `json:"first_seen,omitempty"`
	LastSeen       time.Time `json:"last_seen,omitempty"`

	Versions   []Count `json:"versions"`
	Ciphers    []Count `json:"ciphers"`
	Groups     []Count `json:"groups"`
	ALPN       []Count `json:"alpn"`
	Transports []Count `json:"transports"`
	JA4        []Count `json:"ja4"`
	ServerName []Count `json:"server_names"`

	PQ []Count `json:"pq_status"`
	// PQReadiness is the fraction of handshakes where a post-quantum group
	// was actually negotiated, out of those where the negotiation was
	// observed at all. Flows with no captured server response are excluded
	// rather than counted as failures.
	PQReadiness float64 `json:"pq_readiness"`

	// ECHOffered counts handshakes carrying an encrypted_client_hello
	// extension. Most are GREASE, so this is not a count of hidden
	// destinations — see Record.ECH.
	ECHOffered int `json:"ech_offered"`
	// ECHLikelyGREASE counts destinations where a config_id that varies
	// across connections shows the extension was a decoy.
	ECHLikelyGREASE int `json:"ech_likely_grease"`

	Findings []FindingCount `json:"findings"`

	// DistinctFindings is how many aggregates the flows collapsed to. The
	// ratio to Flows is a measure of how repetitive the traffic is.
	DistinctFindings  int `json:"distinct_findings"`
	AggregatesDropped int `json:"aggregates_dropped,omitempty"`
}

Summary is the roll-up over every record.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL