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 (HubScout → scout 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)
- Validity —
NotBefore/NotAfter,IsExpired, and days-to-expiry - SANs — DNS 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
locally — 127.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_revokedfinding that forces the Cyphers Score to 0. - HSTS — a plain HTTPS GET reading the
Strict-Transport-Securityheader; capturespresent,max-age, and theincludeSubDomains/preloaddirective 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).