Repository navigation
v0.3.0 — Migration horizon, live TLS scanning, certificate chains & scan diffing
Added
-
Migration horizon — Mosca's inequality (
horizon.py). The risk score says
how much vulnerable cryptography exists; it cannot say whether the migration
finishes in time.--secrecy-years X(and optionally--crqc-years Z) applies
X + Y > Z, where Y is estimated from the findings. Deliberately refuses to
invent Z: there is no default, omitting it yieldsno_verdictrather than a
guess, and the always-computable break-even year needs no estimate of Z at
all. Y is a range, counted per cryptographic family rather than per finding
(the design decision is paid once; call sites scale sub-linearly at
1 + log2(n)/2, capped at 4×), and all six coefficients are emitted under
horizon.assumptions. -
Live TLS endpoint scanning (
tlsscan.py,--url,POST /api/v1/scan-url).
Reports the negotiated key exchange, protocol and cipher for an HTTPS host.
TLS 1.3 removed the key exchange from the cipher suite name and Python'sssl
cannot read thekey_sharegroup (set_groups/group_usedneed OpenSSL 3.5+
and Python 3.14), so the ClientHello is built by hand: offer the hybrid groups
but akey_sharefor X25519 only, and a server preferring a hybrid must answer
with a HelloRetryRequest naming it (RFC 8446 §4.1.3). No new dependencies;
89–257 ms. Behind an SSRF guard — https only, ports 443/8443, every resolved
address must be public (including the IPv4-mapped IPv6 forms of loopback,
RFC 1918, link-local/cloud-metadata and CGNAT), rejecting on any private
answer and connecting to the address that was checked rather than re-resolving.
Rate-limited to 10/hour. Scope is one handshake — what a browser does on every
visit — not port or vulnerability scanning. -
Certificate chain analysis (
certchain.py). Every signature in the chain,
not just the leaf: cloudflare.com's leaf is ECDSA P-256 while an intermediate
above it is signed SHA-256 with RSA, which a leaf-only check misses. Includes a
minimal DER/ASN.1 reader, since the signature algorithm and public key are the
two fieldsssl.getpeercert()omits and the package takes nocryptography
dependency. Certificate findings state that the risk is forgery of future
certificates, not retroactive like the key exchange; a root's self-signature
is shown but not scored, because a root is trusted via the client's trust store. -
Scan diffing (
diff.py,--baseline,--fail-on-new,
GET /api/v1/scans/{id}/diff). Lets CI ratchet — tolerate an existing
backlog, fail only when it grows, which--fail-on-highcannot do without
failing every build from day one. Findings are identified without their line
number, so a reformat is not reported as a fix plus a regression, with counts
tracked per key. The verdict is driven by new findings, never the score delta:
the score caps at 100, so a codebase already at the cap can acquire new HIGH
findings with the number unmoved. -
baseline/fail-on-newinputs on the GitHub Action. A missing baseline
file warns and skips the comparison instead of failing, so a first run works. -
Migration horizon on the dashboard (
POST /api/v1/horizon, scan results page).
Two controls -- how long the data must stay confidential, and a slider for the
quantum year -- with the verdict flipping live between ALREADY TOO LATE / AT RISK /
ON TRACK. Y is estimated server-side from the findings and fetched once, so the
effort model stays single-sourced with the CLI; the sliders then recompute
X + Y > Zlocally, because a round trip per drag would make the control unusable.
Only each finding'sfamilycrosses the wire -- file paths and snippets are
irrelevant to the estimate. The card carries a "What this refuses to claim" panel
listing all six coefficients, and degrades to hidden if the backend is cold, since
the findings table is the point of that page. -
"Scan a live website" on the dashboard. The home page exposes the TLS scan
with the key exchange, protocol, cipher and full certificate chain rendered,
and shows the server's own refusal reason for a rejected target. -
Third-party origin sweep (
origins.py,--origins). A site can negotiate a
hybrid on its own domain and still pull scripts, fonts and analytics from a dozen
other origins over classical key exchange. Measured ongithub.com: 2 of 5
third-party origins post-quantum, 3 classical. Produces one aggregate finding rather
than one per origin, at MEDIUM when the page's own key exchange is fine, because a
third-party server is not the author's to fix. A page is attacker-controlled input,
so the SSRF guard runs on every discovered origin, not once on the entry URL;
markup pointing at169.254.169.254is refused and recorded, never followed. Page
size, origin count and each handshake are all bounded. No HTML dependency —
html.parseris in the standard library. -
pqc_hybridandpqc_signaturefamilies, withNO_ACTION_FAMILIES
marking families that are reported but need no migration — so an endpoint that
has already migrated is not billed migration effort, and a post-quantum key
exchange is not told to consider SHA-384. -
A CI check that the documented test counts are true
(tools/check_test_count.py). Every "N tests" in the README and the technical
report is a claim a reader can check by cloning and running pytest, so a stale
one is a published inaccuracy. Keeping them current by hand did not work -- they
drifted five times in a day of feature work, and the figure that stayed wrong
longest was a shields badge, because the badge spells the number differently
from the prose and searching for one does not find the other. The check
collects the count from pytest, compares it against every documented figure,
fails the build on a mismatch, and also fails if a pattern stops matching --
which is what would otherwise happen silently when the wording is reworded.
--fixrewrites the counts in place.
Fixed
- SARIF no longer invents a file and a line for endpoint findings. They were
emitted withartifactLocation.uriset to a bare host plus a bracketed suffix
and a fabricatedstartLine: 1— schema-valid, but it would make GitHub code
scanning annotate a file that does not exist. Endpoint findings now carry the
realhttps://hostURI, no region, and the certificate position as a
logicalLocation. api()in the dashboard now attaches the HTTP status to the errors it throws,
so a deliberate 400 refusal is shown immediately rather than sitting through a
retry intended for a cold backend.