Skip to content

Scout — scanning & scoring

Scout's scanner is why the product exists on the endpoint: it completes real per-version TLS handshakes to measure exactly what an endpoint negotiates, then derives a fixed set of findings. It is pure Go crypto/tls — no hand-rolled TLS, no OpenSSL.

Scout observes; the Hub scores

Scout does not compute the Cyphers Score. It derives raw findings with deduction values and ships them (plus raw cipher/cert facts) to the Hub. The 100-minus-deductions arithmetic, tier mapping, and every "weak" judgment (weak ciphers, weak keys, weak signatures, self-signed) are entirely Hub-side (Rust). Don't read "Scout scores TLS" — it measures and reports.

Where the scanner runs — two roles

The same scanning engine (scout/pkg/scanner) runs in two very different contexts. Keep them straight — the Scout binary runs both out on the fleet and alongside the Hub:

One-shot scout scan The Scout agent
What it is A single, stateless TLS probe of a target — prints one JSON result and exits. No enrollment, no identity, no on-disk state. The long-running daemon (scout agent) — enrolled, holds an mTLS identity, scans continuously, and also discovers on-disk certs, reports posture, and runs the Hub's commands.
Where it runs From the CLI, or spawned by the Hub itself — the Hub shells out to the scout binary (HubScoutscout scan <host> -p <port> -f json) to probe endpoints for Adopt / Discover served checks. That copy runs next to the Hub. On each endpoint server it manages — remotely, one per host across the fleet.
Vantage The Hub's network position — reaching out to a public/remote endpoint. The host's own position — including the local 127.0.0.1 probe of what that box actually serves.

Everything below — version detection, cipher enumeration, certificate inspection, the findings — is the same code in both roles; only where it dials from differs.

TLS version detection

For each of TLS 1.0, 1.1, 1.2, 1.3, Scout dials with MinVersion == MaxVersion pinned to that one version and completes a full handshake — so "supported" means the endpoint genuinely negotiated it, not that a ClientHello was merely sent. This is the legacy capability modern OpenSSL can't provide, and it's proven end-to-end by tests that stand up real TLS 1.0/1.1 servers.

Cipher enumeration

A removal-loop: dial with the full candidate suite list, record the negotiated suite, remove it, repeat until the handshake fails — one dial per discovered cipher, per version, for TLS 1.0–1.2.

TLS 1.3 is a special case (Go limitation)

Go's crypto/tls won't let a client pin an explicit TLS 1.3 suite list, so for 1.3 Scout records only the single suite actually negotiated — not a full enumeration. This is a documented capability ceiling, not a bug.

Scout reports cipher names and bit sizes (a substring heuristic). It does not classify any cipher as weak — RC4/3DES/NULL/EXPORT/anon judgments are computed Hub-side from the raw names Scout sends.

Certificate inspection

From the served leaf, Scout extracts:

  • Subject & issuer — CN plus the full DN
  • Serial — plus its DER-hex form
  • ARI cert id (RFC 9773)
  • ValidityNotBefore / NotAfter, IsExpired, and days-to-expiry
  • SANsDNS names only (IP and email SANs are dropped)
  • Key — type and size
  • Signature algorithm
  • Fingerprints — leaf and SPKI (both SHA-256)
  • SCT counts — embedded and TLS

From the presented chain, per intermediate: subject/issuer, expiry, signature algorithm, IsCA, and fingerprint — but no key type/size. Scout then runs a real cert.Verify() against the OS root pool and records ChainTrusted / ChainTrustError.

Local probe · verification-off handshake · OS trust as a data point

When Scout assesses the host it runs on, it connects to the endpoint locally127.0.0.1:port, sending the real domain as SNI (ScanWithSNI) — so it captures exactly what that box serves, even behind a reverse proxy or CDN. The handshake itself runs with verification off (InsecureSkipVerify), so an expired, self-signed, or wrong-name endpoint still gets scored rather than rejected. Chain trust is judged separately, as a data point: cert.Verify() validates the captured chain against the host's OS trust store (Go's system root pool) → ChainTrusted / ChainTrustError.

Net: Scout evaluates against OS trust, but doesn't require it to scan.

ChainTrusted is data, not a Scout finding

Scout computes and ships chain-trust, but never turns it into one of its own findings — the Hub's enrich_chain_findings consumes it. So Scout doesn't "flag untrusted chains"; it provides the data point.

Served-configuration profiles. Scout also probes with fixed modern and legacy client profiles plus a genuine no-SNI probe — each can capture a different leaf/chain. This is how it detects a CDN/proxy serving a weaker cert to legacy clients, or a default-vhost cert on a shared IP.

HSTS & OCSP stapling

  • OCSP stapling — keys off the real stapled response in the handshake, not the responder URL in the cert's AIA, and validates it: the staple must parse, verify to the leaf's issuer, report status good, and be fresh (within its thisUpdate/nextUpdate window). An out-of-date or unverifiable staple counts as not stapled (trips the stapling-missing finding); a staple that reports the served cert revoked raises a fatal cert_revoked finding that forces the Cyphers Score to 0.
  • HSTS — a plain HTTPS GET reading the Strict-Transport-Security header; captures present, max-age, and the includeSubDomains / preload directive flags.

The 10 Scout-derived findings

These are the complete, fixed set Scout emits (with the deduction each carries, locked to a cross-language golden fixture):

Finding Severity Deduction Fires when
tls_10_enabled critical 25 TLS 1.0 supported
tls_11_enabled critical 25 TLS 1.1 supported
tls_13_missing medium 10 TLS 1.3 not supported
beast_vulnerable critical 25 TLS 1.0 supported and a CBC cipher negotiated
cert_expired critical 25 served cert expired
cert_expiring_30d high 10 not expired, < 30 days left
ocsp_stapling_missing low 5 no stapled OCSP response
cert_name_mismatch critical forces score to 0 scanned host matches no SAN (Hub overrides to 0, not −25)
hsts_missing medium 10 no HSTS header (info, not fail, if the probe errored)
hsts_weak_max_age low 5 HSTS present, max-age < 1 year

Everything else finding-shaped is Hub-derived

Weak ciphers (weak_cipher_critical, weak_cipher_3des), weak keys (weak_key_rsa < 2048, weak_key_ecdsa < 256), weak signatures (SHA-1, MD5), self_signed, cert_expiring_7d, and forward-secrecy/chain enrichment are all computed Hub-side by re-deriving from the raw scan JSON Scout already sent. The Hub's enrichment is additive and skips any check Scout already emitted.

Scan bounds

Every dial has a 10s timeout, and a single 2-minute overall wall-clock budget is threaded through every probe/enumeration/HSTS dial in one scan, so a stalling target returns a partial result instead of multiplying timeouts. ScanWithSNI dials one host but sends a chosen SNI (for probing localhost behind a reverse proxy); a genuine no-SNI probe avoids Go's address-inferred SNI leak. When Scout can't reach a target publicly, it falls back to a localhost/SNI probe (only accepting it if a valid, non-expired cert is captured).