Recipes — tool import and chain examples

The recipes follow the cookbook grammar. Problem names the operational need. Solution is the exact command to run. Discussion explains the mechanics behind it. Every Solution in this page ran. Its Observation carries the captured output of that run. Nothing here is aspirational. pqscan reads upstream output as input lines. It needs no extra flag unless the format needs disambiguation.

%%{init: {"theme":"base","themeVariables":{"fontFamily":"ui-sans-serif, system-ui","fontSize":"14px","primaryColor":"white","primaryTextColor":"rgb(39,128,227)","primaryBorderColor":"rgb(17,17,17)","lineColor":"rgb(52,58,64)","clusterBkg":"white","clusterBorder":"rgb(113,38,249)","clusterLabelBackground":"white"},"flowchart":{"curve":"basis","nodeSpacing":50,"rankSpacing":70,"padding":30,"subGraphTitleMargin":{"top":0,"bottom":16},"edgeLabelBackground":"white","htmlLabels":true}}}%%
flowchart TB
  subgraph DISC["Discovery layer — compose, not rebuild"]
    S[subfinder — domains]
    N[naabu — host:port truth]
    M[masscan / zmap — internet port sweep]
  end
  subgraph PQ["pqscan — cipher-configuration plane"]
    P1["fast — 1 handshake"]
    P2["deep — the offer-one matrix (≤24 standard, 80 full)"]
    P3["pq — PQ ladder + verdicts"]
  end
  subgraph SINK["Downstream"]
    J[jq triage / diffs]
    U[nuclei — tls tags]
    R[CSV / WAL / CI gate]
  end
  DISC --> PQ
  P1 --> P2
  P1 --> P3
  P2 --> J
  P3 --> U
  P3 --> R

Input chains

Domain discovery → cipher truth

Problem. List the live subdomains of a zone. Then read the key-exchange group and the cipher suite the TLS stack actually negotiates against a modern client. The wire answers — the administrator’s configuration does not.

Solution.

subfinder -d shain.io -silent \
  | httpx -silent -no-color \
  | pqscan -jsonl -o assets.jsonl

Observation. One schema-1 record per endpoint. This run’s chain found 4 alive endpoints. The first record carries the tools’ stamps at capture time: subfinder v2.16.0 → httpx v1.12.0:

{"schema":1,"mode":"fast","target":{"input":"oi.shain.io","host":"oi.shain.io","ip":"","port":443,"ptr":"","sni_used":""},"handshake":{"negotiated":4588,"group_name":"X25519MLKEM768","group_class":"pq_hybrid","suite":"TLS_AES_128_GCM_SHA256","version":"tls-1.3","alpn_neg":"h2"},"quic":{"attempted":false,"state":"skipped: no alt-svc h3 hint","negotiated":0,"group_name":"","group_class":"","discovered":{"alt_svc_h3":false,"h3_direct":false}},"cert":{"sig_alg":"sha256WithRSAEncryption","sig_pq":false,"key_alg":"RSA","key_pq":false,"dual":false},"pq":{"score":40,"label":"pq-partial","hndl":false,"summary":"negotiated_pq_hybrid(+40)"},"error":""}

Discussion. subfinder streams bare domains. httpx resolves the domains and probes them. It answers scheme://host lines. The input grammar strips those lines to host specs natively. The chain needs no flag and no adapter. The probe is browser-grade. The ClientHello offers the stdlib default group preference order (MLKEM hybrid first). It offers ALPN h2,http/1.1. It records what the server SELECTED. Negotiation is the assertion. No configuration parsing. Each upstream tool keeps its own job. Nothing in the chain re-implements another tool’s job. The text chain needs only the tools. A httpx -csv render refuses at import — the documented boundary. The text dialect is the whole import contract.

Port truth first

Problem. Only hosts that actually accept on a given port need a probe handshake. A closed socket wastes the retry budget. It skews the latency numbers.

Solution.

naabu -l domains.txt -p 443,8443,465,993 -silent \
  | pqscan -jsonl

Observation. naabu’s host:port lines are first-class target specs. One probe handshake and one record go out per surviving socket (naabu 2.6.1 at capture time).

Discussion. naabu is the port-discovery plane. It answers with the sockets that accept. That answer is the input pqscan wants. The certificate-directory collection and the resumption analysis reuse these records later. They do not re-dial. -from-naabu also covers naabu’s bare-host-and-ports-block rendering. The host:port form above is already first-class. A saved render also scans through the flag form (the next recipe).

Import dialects render as files — -from-httpx / -from-naabu / -from-nuclei

Problem. The import tools carry three render dialects. httpx answers scheme-URI lines (https://host:port, plus an optional URL path suffix). naabu answers host:port lines. It also renders a host line followed by its bare-port block. nuclei -j streams findings JSONL (one JSON finding per line; its matched-at field carries the probed host). The render may already sit on disk. The operator reads that file without a cleanup pass: the adapters accept the render the way any target file is accepted.

Solution.

cat domains.txt | httpx -silent -ports 443,8443 -p 443,8443 -no-color > discovered.httpx   # the pinned capture (httpx v1.12.0)
pqscan -from-httpx discovered.httpx -jsonl

naabu -l domains.txt -p 443,8443,465,993 -silent > naabu.naabu   # the pinned capture (naabu 2.6.1)
pqscan -from-naabu naabu.naabu -jsonl

nuclei -l hosts.txt -silent -j > nuclei.jsonl   # the pinned capture (nuclei v3.11.1)
pqscan -from-nuclei nuclei.jsonl -jsonl

Observation. The imports ran with the real tools. Each import read its pinned capture of that exact command. The upstream renders stay verbatim. The records come from the pqscan legs. The httpx render’s scheme/URI lines strip to host:port specs:

{"schema":1,"mode":"fast","target":{"input":"example.org","host":"example.org","ip":"","port":443,"ptr":"","sni_used":""},"handshake":{"negotiated":4588,"group_name":"X25519MLKEM768","group_class":"pq_hybrid","suite":"TLS_AES_128_GCM_SHA256","version":"tls-1.3","alpn_neg":"h2"},"quic":{"attempted":true,"state":"negotiated","negotiated":4588,"group_name":"X25519MLKEM768","group_class":"pq_hybrid","discovered":{"alt_svc_h3":true,"h3_direct":true}},"cert":{"sig_alg":"ecdsa-with-SHA256","sig_pq":false,"key_alg":"ECDSA","key_pq":false,"dual":false},"pq":{"score":40,"label":"pq-partial","hndl":false,"summary":"negotiated_pq_hybrid(+40)"},"error":""}

and the naabu render’s doubled host:port lines (example.com:8443, example.com:443 twice over — naabu renders per resolved address). They dedupe to one record per socket:

{"schema":1,"mode":"fast","target":{"input":"example.com:8443","host":"example.com","ip":"","port":8443,"ptr":"","sni_used":""},"handshake":{"negotiated":4588,"group_name":"X25519MLKEM768","group_class":"pq_hybrid","suite":"TLS_AES_128_GCM_SHA256","version":"tls-1.3","alpn_neg":"h2"},"quic":{"attempted":false,"state":"skipped: no alt-svc h3 hint","negotiated":0,"group_name":"","group_class":"","discovered":{"alt_svc_h3":false,"h3_direct":false}},"cert":{"sig_alg":"ecdsa-with-SHA256","sig_pq":false,"key_alg":"ECDSA","key_pq":false,"dual":false},"pq":{"score":40,"label":"pq-partial","hndl":false,"summary":"negotiated_pq_hybrid(+40)"},"error":""}

the import: the pinned capture (nuclei v3.11.1, hosts.txt = scanme.sh, the default template set) answers 28 findings on stdout. The import reads the file. The one dedupe step collapses the repeated sockets to two records — the ssh port’s record (a real error record: the TCP plane reached an ssh service) and the https port’s record:

{"schema":1,"mode":"fast","target":{"input":"scanme.sh:22","host":"scanme.sh","ip":"","port":22,"ptr":"","sni_used":""},"handshake":{"negotiated":0,"group_name":"","group_class":"","suite":"","version":"","alpn_neg":""},"quic":{"attempted":false,"state":"","negotiated":0,"group_name":"","group_class":"","discovered":{"alt_svc_h3":false,"h3_direct":false}},"cert":{"sig_alg":"","sig_pq":false,"key_alg":"","key_pq":false,"dual":false},"pq":{"score":0,"label":"","hndl":false,"summary":""},"error":"tls: first record does not look like a TLS handshake"}
{"schema":1,"mode":"fast","target":{"input":"scanme.sh:443","host":"scanme.sh","ip":"","port":443,"ptr":"","sni_used":""},"handshake":{"negotiated":29,"group_name":"x25519","group_class":"classic","suite":"TLS_AES_128_GCM_SHA256","version":"tls-1.3","alpn_neg":"h2"},"quic":{"attempted":false,"state":"skipped: no alt-svc h3 hint","negotiated":0,"group_name":"","group_class":"","discovered":{"alt_svc_h3":false,"h3_direct":false}},"cert":{"sig_alg":"ecdsa-with-SHA256","sig_pq":false,"key_alg":"ECDSA","key_pq":false,"dual":false},"pq":{"score":0,"label":"scan-error","hndl":false,"summary":"weak_classical_preferred(-5)"},"error":""}

Discussion. The adapters feed the SAME composition the -u/-l/-- parse path uses. internal/target strips the dialect decoration. Every line goes through the one grammar. One dedupe step serves every input source in flag order. The key is host:port; first occurrence wins. A bad line refuses the run named by its flag (exit 64). The run never scans a truncated list. The httpx -csv direction is out of contract by decision. The pinned text command is the whole httpx import. The block rendering of a naabu multi-port run parses through -from-naabu exactly as the host:port lines do. The block is a host line followed by bare-decimal port lines (the adapter’s unit fixtures pin it). The -from-* adapters read files. The findings arrive on stdout of the pinned nuclei -j capture. The recipe writes them to the file the import reads. A -from-nuclei - value reads as a file name (open -: no such file or directory, exit 64). nuclei ... -j writes JSONL to stdout. > findings.jsonl puts it on disk.

Internet scale — one box of a four-box pass

Problem. A /16-class target list needs a TLS sweep in one pass. The run must stay off a 16-hour serial tail. One box’s failure must not orphan the run’s output.

Solution.

masscan -p 443 -iL cidrs.txt --rate=100000 --open-only -oX - \
  | pqscan -jsonl -o shard1.jsonl

Observation. masscan answers the port question at line rate. pqscan answers the cipher-configuration question per surviving socket. One schema-1 record stream leaves the other side.

Discussion. The two tools compose because their contracts are narrow. masscan’s -oX - and --open-only keep the pipe to the sockets that accept. pqscan adds the negotiated group, suite, ALPN, QUIC and certificate blocks per host. The shard split is the flag’s job now: -shard i/n filters the composed targets to one box’s slice. The rule: fnv1a64(host:port) % n == i-1. The shards stay disjoint and complete at a fixed total. The four boxes run the same list. Each box runs its own -shard 1/4 … 4/4. The union of the four record streams is the run. The stderr note carries the slice’s count (shard 1/4: N of M targets). Each box bounds its own handshake budget with -rate/-threads. A re-run replays the WAL. It does not re-dial the surviving targets. Live partition proof on a 9-target list; both slice notes:

pqscan: shard 1/2: 4 of 9 targets
pqscan: shard 2/2: 5 of 9 targets

Target grammar and its refusals

Problem. The input contract is exact. The contract names what pqscan accepts. It names what pqscan refuses. A scanner fails closed on the malformed target specs. It never mis-scans.

Solution.

pqscan -u example.com:8443 -jsonl      # host:port
pqscan -u 2001:db8::1 -jsonl           # v6 literal
echo http://example.org:8443 | pqscan -jsonl -   # scheme:// strips on the stdin alias
echo 'https://example.org:443/api' | pqscan -jsonl   # refused: a URL path suffix
printf '198.51.100.0/24\n' | pqscan -jsonl           # refused: CIDR spec

Observation. The first three run. The scheme strips off every spec line (-u, -l lines, the piped stdin). One schema-1 record goes out per target. example.com:8443 negotiates. The unreachable v6 literal is a record with error set. It is not a refusal:

{"schema":1,"mode":"fast","target":{"input":"example.com:8443","host":"example.com","ip":"","port":8443,"ptr":"","sni_used":""},"handshake":{"negotiated":4588,"group_name":"X25519MLKEM768","group_class":"pq_hybrid","suite":"TLS_AES_128_GCM_SHA256","version":"tls-1.3","alpn_neg":"h2"},"quic":{"attempted":false,"state":"skipped: no alt-svc h3 hint","negotiated":0,"group_name":"","group_class":"","discovered":{"alt_svc_h3":false,"h3_direct":false}},"cert":{"sig_alg":"ecdsa-with-SHA256","sig_pq":false,"key_alg":"ECDSA","key_pq":false,"dual":false},"pq":{"score":40,"label":"pq-partial","hndl":false,"summary":"negotiated_pq_hybrid(+40)"},"error":""}

What refuses (exit 64, nothing scanned): a URL path suffix on the spec path (path stripping stays the adapters’ job). A CIDR spec line also refuses (per-address expansion is planned):

pqscan: URL path "/api" in target spec "https://example.org:443/api": a URL path is the -from-httpx adapter's decoration (-from-httpx FILE strips scheme and path; the spec path keeps host[:port])
pqscan: -cidr target "198.51.100.0/24": cidr expansion is not supported

Discussion. The grammar takes these forms. Bare hosts (443 default). host:port. IPv4/IPv6 literals (bracketed v6 with port). scheme://host[:port] (the scheme strips on every spec line). i/n shard specs. The stdin forms below. The pipe is the implicit default input (no marker needed). -l - is the explicit stdin-file alias. -- composes beside -u/-l. Every flag also parses under two dashes (--mode is -mode). The completions offer both spellings. Specs dedupe by host:port in input order. Unreachable endpoints are NOT refusals. They are records with error set. The failure-taxonomy recipe below classifies them. CIDR spec lines refuse. Per-address expansion is not supported (the grammar parses the class; the expansion is the boundary).

Modes: fast, deep, pq

Deep standard — the bounded ≤24-probe profile

Problem. Beyond the one fast handshake: the per-rung answers for every named group. The QUIC plane’s answers for the same rungs, for the RFC 9369 transport version, and for one completed HTTP/3 exchange. The ML-DSA signature offer set. The HRR demand. Deep standard reads these out without the full matrix’s cost on every asset.

Solution.

pqscan -u google.com -mode deep -jsonl

Observation. The record gains a deep block. This run’s counts and a finding excerpt (the 24th finding is the HRR row):

mode=standard attempted=24 ok=3 rejected=9
group/4585    rejected   alert 40
group/4586    rejected   alert 40
group/4587    rejected   remote error: tls: handshake failure
group/4589    rejected   remote error: tls: handshake failure
group/4590    rejected   alert 40
group/25      rejected   remote error: tls: handshake failure
group/29      accepted   negotiated x25519, suite TLS_AES_128_GCM_SHA256, tls-1.3
quic/v2       rejected   no compatible QUIC version found (we support [v2], server offered [0x4a6a1afa v1 draft-29])
quic/h3-exchange accepted h3 exchange completed: status 301 (GET /, alpn h3, quic-v1)
sigalg/mldsa65 refused    not-offerable: the raw ClientHello path pins its signature_algorithms list (custom-list offers are not offered)
hrr/x25519-shareless  accepted   hrr: the server requested group x25519 (29)

with the text line carrying the counts tail deep=standard:24p/3ok/9rej. The remaining 12 of the 24 findings are the refused rows: three classical groups the stdlib cannot offer, three QUIC rungs, three ML-DSA sigalgs, three resumption vectors. Each row is its own finding.

Discussion. The profile handshake runs first. Its facts prune the plan. The plan never re-probes the negotiated group. It never re-probes the negotiated (suite, version) pair. It never re-probes the negotiated version. It never re-probes the certificate probe. It never re-probes the QUIC rung the profile already answered. The plan then cuts to the ≤24 budget through the documented prune classes. The classes run in order. Adjacent suites first. Then suites on the observed version. Then the remaining suites. Then the foreign-version probes. Then the classic signature algorithms. The prune classes never touch: the forced-group offer set. The ML-DSA sigalgs. The cert probe. The 0-RTT probe. The HRR probe. On a modern host (the live reference above) the plan keeps exactly the structural offer sets: the group offer set, the QUIC offer set, the sigalg offer set, resumption, HRR. The legacy suite and version hunt lives in -deep full. Per-kind dispatch: a group probe offers ONE named group. The offer is a real share where the toolchain holds one. Where the toolchain holds none, the probe sends a share-less raw offer (the obsolete-Kyber oracle). refused rows are locally not-offerable probes. The raw ClientHello path pins its signature_algorithms list. The resumption plane is not instrumented yet. The finding’s detail says exactly that. A refused row is a capability boundary. It is not a server answer. The HRR probe extracts the group the server DEMANDED when the client withheld a share. The v2 rung offers the RFC 9369 transport version alone. A version-negotiation miss maps the server’s answer to a rejected row, verbatim. A v2 negotiation maps the accepted row with the quic-v2 tail. The h3-exchange rung reads the server’s SETTINGS frame after the v1 handshake and runs one HTTP/3 GET. A completed exchange maps the accepted row with the status. A negotiated handshake that completes no exchange maps its own rejected row.

Deep full — the complete offer-one matrix

Problem. Deep full gives exhaustive coverage: every suite × version pair (the secure default list AND the legacy/insecure suites). Every registry group. The signature-algorithm surface. The resumption vectors. The QUIC plane’s rungs. The HRR hop. Each row is one handshake.

Solution.

pqscan -u google.com -mode deep -deep full -jsonl

Observation. (live) The record’s deep block counts:

{"mode":"full","attempted":82,"ok":29,"rejected":34}

(The remaining 19 findings are the refused rows.) Sample suite findings (the legacy hunt lives here):

suite/TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA@1.0   accepted   negotiated TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls-1.0
suite/TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA@1.2   accepted   negotiated TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls-1.2
suite/TLS_CHACHA20_POLY1305_SHA256@1.3           refused    not-offerable on tls-1.3: crypto/tls offers the fixed 1.3 suite set (offer-one is not client-restrictable)
suite/TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA@1.2     rejected   remote error: tls: handshake failure

The QUIC plane’s rungs ride the same list. The v2 rung answers the version-negotiation miss here as a rejected row, verbatim:

quic/v2            rejected   no compatible QUIC version found (we support [v2], server offered [v1 0xeaba1aba draft-29])
quic/h3-exchange   accepted   h3 exchange completed: status 301 (GET /, alpn h3, quic-v1)

Discussion. 82 probes run. Observed facts prune none: 4 versions, 11 registry groups (4585–4590 plus the classical set), 47 suite×version pairs, 7 signature algorithms (incl. the ML-DSA codepoints), 1 certificate, 3 resumption vectors, 1 HRR, plus the 8 QUIC-plane rungs. The 8 QUIC rungs carry: the 6 group rungs, the RFC 9369 transport-version rung, the functional HTTP/3 rung. The list auto-tracks crypto/tls. A Go upgrade that gains a suite grows the matrix. Standard never exceeds its 24-probe budget. full trades the budget for coverage. Point it at ONE asset. It is not the run for a target list.

-mode pq — the PQ offer set

Problem. Which post-quantum groups does this server accept rung by rung — independent of the client’s default preference order. Does it still negotiate an obsolete draft.

Solution.

pqscan -u google.com -mode pq -jsonl

Observation. (live) The record’s handshake block carries the offer set’s first ACCEPTED hybrid. The offer set lands as a deep-mode-shaped block: mode:"pq" with the counts 8 attempted / 1 ok / 7 rejected and one finding per rung. The -text line rides the same counts as the tail deep=pq:8p/1ok/7rej:

group/4588   accepted    negotiated X25519MLKEM768, suite TLS_AES_128_GCM_SHA256, tls-1.3
group/4590   rejected    alert 40
group/25497  rejected    alert 40
group/25498  rejected    alert 40

under -explain the reading names the accepted rung and the refusals in operator words:

  - deep: the forced PQ offer set sent 8 offers: 1 accepted, 7 refused with the server's reasons, 0 not-offerable; accepted: group/4588; each forced PQ-only group refused unless the server supports that group — the per-probe detail lives in the jsonl findings.

Discussion. -mode pq runs the PQ offer set: 4585–4590 plus the Kyber drafts (25497/25498). The readiness rating rides the record’s pq block. A draft-HRR acceptance trips pq.hndl — the harvest-now-decrypt-later flag. Material intercepted against a draft-group handshake decrypts when the draft breaks. The rest of the suite may look modern. The obsolete-Kyber oracle is the share-less offer of 25497. The HRR answers by demanding the draft. That finding feeds the compat decisions. Schedule this mode FIRST on any asset whose pq.label reads below pq-ready-hybrid.

The cipher questions

Which ciphers does the server accept?

Problem. For a given host: does the stack support a specific cipher suite? Or does it refuse one? Is ChaCha20-Poly1305 in the modern set? Is the legacy CBC family still open on TLS 1.0–1.2? The wire answers each question with one handshake.

Solution.

pqscan -u google.com -mode deep -deep full -jsonl \
  | jq -r '.deep.findings[] | select(.probe | startswith("suite/")) | "\(.probe)\t\(.result)\t\(.detail)"'

Observation. (live, excerpt)

suite/TLS_AES_128_GCM_SHA256@1.3            refused   not-offerable on tls-1.3: crypto/tls offers the fixed 1.3 suite set (offer-one is not client-restrictable)
suite/TLS_CHACHA20_POLY1305_SHA256@1.3      refused   not-offerable on tls-1.3: crypto/tls offers the fixed 1.3 suite set (offer-one is not client-restrictable)
suite/TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA@1.0   accepted   negotiated TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls-1.0
suite/TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA@1.2   accepted   negotiated TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls-1.2

Discussion. Each suite probe is its OWN handshake: it offers ONE suite (offer-one / observe-accept). No shared negotiation sits behind the row. An accepted row is unambiguous. TLS 1.0–1.2 suites are individually forceable. TLS 1.3’s ciphers ride one fixed set. crypto/tls cannot restrict it. Those rows read refused with the reason in the detail. The 1.3 SET acceptance still shows in the record’s handshake.suite. The profile handshake sets that field. The -probe-suite override (planned) offers suites not in the registry list. Until it lands, the matrix covers the stdlib lists. Those lists auto-track per Go release.

Which TLS versions does the server still accept?

Problem. The legacy-version floor is the standard audit question: is TLS 1.0/1.1 still open? Which legacy stacks still serve?

Solution.

pqscan -u google.com -mode deep -deep full -jsonl \
  | jq -r '.deep.findings[] | select(.probe | startswith("version/")) | "\(.probe)\t\(.result): \(.detail)"'

Observation. (live)

version/1.0    accepted: negotiated TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls-1.0
version/1.1    accepted: negotiated TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls-1.1
version/1.2    accepted: negotiated TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls-1.2
version/1.3    accepted: negotiated TLS_AES_128_GCM_SHA256, tls-1.3

Discussion. The version hunt runs in the full matrix. When the budget gets tight, the standard plan’s prune classes drop the foreign-version probes. (The live reference’s standard run planned none: the offer sets already filled the 24-probe budget.) Pair the version rows with the suite rows. The 1.0/1.1 answers negotiated an ECDSA-keyed CBC suite. A legacy-only answer. The fast plane never shows it: the browser-grade handshake always lands 1.3. The deep modes exist for this reason. The fast record reads “modern and hybrid”. The deep findings add “and still answering on a decade-old CBC stack”.

Which PQ groups does the server accept when probed?

Problem. Not negotiation-by-default: forced acceptability. Which of the ML-KEM hybrids (4585–4590) and the legacy drafts does this server take? What alert answers each refusal?

Solution.

pqscan -u google.com -mode deep -jsonl \
  | jq -r '.deep.findings[] | select(.probe | startswith("group/")) | "\(.probe)\t\(.result)\t\(.detail)"'

Observation. (live)

group/4585    rejected   alert 40
group/4586    rejected   alert 40
group/4587    rejected   remote error: tls: handshake failure
group/4589    rejected   remote error: tls: handshake failure
group/4590    rejected   alert 40
group/25      rejected   remote error: tls: handshake failure
group/26      refused    not-offerable locally: tls: no supported key exchange methods (CurveIDs)
group/27      refused    not-offerable locally: tls: no supported key exchange methods (CurveIDs)
group/28      refused    not-offerable locally: tls: no supported key exchange methods (CurveIDs)
group/29      accepted   negotiated x25519, suite TLS_AES_128_GCM_SHA256, tls-1.3

Discussion. rejected rows carry the server’s own alert. Alert 40 is the group refused at the ClientHello. The tls: handshake failure form is the server’s stricter mid-handshake reply. Either way the server ANSWERED and said no. That is a finding, not a failure. refused rows are the honest tool boundary: no stdlib share exists for that codepoint yet. The HRR finding closes the set. The client withholds everything. The server DEMANDS a group in its HelloRetryRequest. That demand is the last acceptability datum.

Which signature algorithms are offered / possible?

Problem. PQ certificate signatures (ML-DSA/SLH-DSA) on the wire: what is provable today? Where is the boundary?

Solution.

pqscan -u google.com -mode deep -jsonl \
  | jq -r '.deep.findings[] | select(.probe | startswith("sigalg/")) | "\(.probe)\t\(.result)\t\(.detail)"'

Observation. (live; every sigalg row refuses with its reason)

sigalg/mldsa44   refused   not-offerable: the raw ClientHello path pins its signature_algorithms list (custom-list offers are not offered)
sigalg/mldsa65   refused   not-offerable: the raw ClientHello path pins its signature_algorithms list (custom-list offers are not offered)
sigalg/mldsa87   refused   not-offerable: the raw ClientHello path pins its signature_algorithms list (custom-list offers are not offered)

Discussion. The certificate analytics are provable today. They ride the record’s cert block: the leaf chain’s signature algorithm (ecdsa-with-SHA256), the key algorithm, the PQ-ness, the dual-cert rule. cert.dual goes true when a classical-signed and a PQ-signed member coexist in the offered chain. Custom signature_algorithms offers in the raw ClientHello are the planned surface. Until then, the sigalg probe rows state the boundary per finding. They never vanish. The -probe-sigalgs override (planned) offers arbitrary lists.

Debugging and fault logging

Logging a fault: -debug and the live stream

Problem. A run can misbehave. The operator then needs the full decision trail: human-readable and live.

Solution.

pqscan -u 127.0.0.1:64444 -debug

Observation. (live, stderr + the record on stdout)

  21:43:45.775 [info][config] run : diagnostics armed  positional_args= version=0.0.1
  21:43:45.775 [info][engine] pipeline : multi-target scan started  max_window=1 progress_every=50 rate=0 total=1
  21:43:45.776 [error][tls] scan 127.0.0.1: TCP probe refused (dial tcp 127.0.0.1:64444: connect: connection refused)
  21:43:45.776 [info][engine] pipeline 127.0.0.1:64444: progress reported  done=1 total=1
pqscan: progress: 1/1 targets scanned
127.0.0.1:64444 group=- class=- suite=- version=- cert=-/- error=dial tcp 127.0.0.1:64444: connect: connection refused
  21:43:45.776 [info][output] emit 127.0.0.1: record emitted  format=text
── pqscan diagnostics ── schema 1 ── 0.0.1 (go1.27.2, darwin/arm64)
run: 2026-10-20T21:43:45.775767+11:00 → 2026-10-20T21:43:45.776132+11:00
flags: debug=true u=127.0.0.1:64444
events (5):
  21:43:45.775 [info][config] run : diagnostics armed  positional_args= version=0.0.1
  21:43:45.775 [info][engine] pipeline : multi-target scan started  max_window=1 progress_every=50 rate=0 total=1
  21:43:45.776 [error][tls] scan 127.0.0.1: TCP probe refused (dial tcp 127.0.0.1:64444: connect: connection refused)
  21:43:45.776 [info][engine] pipeline 127.0.0.1:64444: progress reported  done=1 total=1
  21:43:45.776 [info][output] emit 127.0.0.1: record emitted  format=text
summary: 5 events (1 errors, 0 warnings)

Discussion. Every decision point emits an event: plan steps, QUIC handshakes, refusal classifications, timeouts, rate and breaker decisions, WAL writes, emissions. The [level][domain] prefix is the taxonomy: [error][tls] vs [warn][quic] vs [info][engine]. The trailer block restates the run’s shape (flags, window, totals). A pasted stderr then reads self-describing.

Attaching the machine-readable fault: -debug-out

Problem. A ticket needs the structured event log, not a paste. Downstream tooling wants to query by domain, level and target.

Solution.

pqscan -u hostile.internal -quic-off -debug -debug-out diag.json
jq -c '.summary' diag.json
jq -r '.events[] | "\(.level)[\(.domain)] \(.op) \(.target // "-")"' diag.json

Observation. (live)

{"events":5,"errors":1,"warnings":0}
info[config] run -
info[engine] pipeline -
error[tls] scan hostile.internal
info[engine] pipeline hostile.internal
info[output] emit hostile.internal

Discussion. -debug-out writes the full event log as JSONL beside the run. The log is independent of -debug’s stderr stream. Arm one writer. Arm both at once. The event objects carry level, domain, op, target, error and timing. The live view renders the same stream. BUG REPORTS AND SCAN-TOUCHING FEATURE REQUESTS attach -debug -debug-out diag.json output. The issue templates demand that attachment. The event log is the only reproduction that shows the tool’s decisions.

The failure taxonomy: reading scan results correctly

Problem. A red error line is not always a failure. A refused OFFER is a finding. A timeout is an infrastructure problem. A breaker is the tool protecting itself. Triage needs the classes.

Solution.

pqscan -l estate.txt -jsonl \
  | jq -r 'select(.error != "") | "\(.target.host): \(.error)"' | sort | uniq -c | sort -rn

Observation. The classes and their meanings:

class reads as meaning
refused (dial) connect: connection refused, no route to host nothing listening; the target is wrong or down
timeout / deadline i/o timeout, context deadline exceeded the deadline ladder (connect 2 s / handshake 4 s / probe 8 s, ±20 % jitter) fired — throttled or silently dropping
rejected (server) alert 40, tls: handshake failure, tls: bad certificate the server ANSWERED and said no — a finding, not a fault
refused (local) not-offerable locally: … pqscan cannot construct the offer — a tool capability boundary, stated in the record
breaker open breaker open the host’s own failure budget tripped; no dial attempted for subsequent targets

Discussion. Only the first two classes read “the target set is unreachable”. The rejected rows are ANSWERS. They feed the deep modes’ findings and the rubric. The local refused class should stay zero for every supported codepoint. The release regression tests keep it at zero. The breaker row is the congestion plane doing its job — see the hostile-hosts recipe. The exit code stays zero for completed scans. Per-target error records do not move it. A non-zero exit means the RUN itself failed (binary fault, WAL unwritable, flag misuse).

Hostile or flaky targets: rate, window, breakers

Problem. Dead subnets must not stall the run. A struggling host must not take extra probe load.

Solution.

pqscan -l estate.txt -rate 2000 -threads 64 -jsonl

Observation. (live, -rate 5 on 3 closed local ports — time shows the bucket’s launch-cadence floor; the same run uncapped for contrast)

real    0m0.412s        pqscan -l t3.txt -rate 5   (3 launches ≥ 0.4s at 5 rps: the bucket's floor)
real    0m0.019s        pqscan -l t3.txt           (uncapped)

Discussion. -rate meters launches through a token bucket. -threads sets the in-flight cap. AIMD owns the window: grow on successes, halve on probe timeouts, floor at one. A host that fails consecutively trips ITS breaker. Later targets on that host emit breaker open error records WITHOUT a dial (the taxonomy table’s row). The cooldown keeps the breaker open. Then it half-opens on a single probe and closes on success. The defaults are unbounded-sequential. The flags shape the run for the target list.

Crash-resume and the WAL journal

Problem. A long run dies at 80 %. Do not pay for 80 % of the handshakes twice. Do not lose the completed set.

Solution.

pqscan -l estate.txt -o out.jsonl          # interrupted mid-run
pqscan -l estate.txt -o out.jsonl -resume  # scans only the remainder

Observation. (live)

pqscan: resume: WAL out.wal: 1 of 3 targets already complete; scanning 2
pqscan: progress: 2/2 targets scanned

Discussion. The WAL is an append-only journal beside the output file. Every completed target lands there fsync-batched, under a monotone sequence and a run id. A SIGKILL loses nothing but the socket in flight. The output may run short. The journal does not. A resume replays completed targets into the output (records ride through unchanged). It probes only the difference. A run restarted with a different target list composes: the journal’s keys are host:port, deduped in input order. The SLO: CI measures a 50-target run at ≤ 6 s wall for the half.

Records, output and triage

The record and its consumers

Problem. A scan’s value is downstream readability: triage, diffs, machine consumption. The consumers need no bespoke parser.

Solution.

pqscan -l estate.txt -jsonl -o estate.jsonl        # streaming records
pqscan -l estate.txt -csv  -o estate.csv           # spreadsheet-shaped
pqscan -l estate.txt -urls | nuclei -silent -tags tls   # URL re-emission

Observation. The CSV contract is the record’s flat form: 25 columns with the header on the first line:

target,host,port,ptr,sni,negotiated,group_name,group_class,suite,version,alpn,quic_state,quic_group,udp_blocked,cert_sig_alg,cert_sig_pq,cert_key_alg,cert_key_pq,cert_dual,pq_score,pq_label,pq_hndl,pq_summary,error,ts
google.com,google.com,443,,,4588,X25519MLKEM768,pq_hybrid,TLS_AES_128_GCM_SHA256,tls-1.3,h2,negotiated,X25519MLKEM768,false,ecdsa-with-SHA256,false,ECDSA,false,false,40,pq-partial,false,negotiated_pq_hybrid(+40),,2026-10-20T10:25:32.034802Z

Discussion. The writers are hand-rolled. No reflection runs. No encoding/json sits on the hot path. The goldens in the test tree pin the column order in the CSV and the key order in the JSONL. A contract change shows as a visible golden diff in review. A silent serde behavior change cannot hide. The default output is the text one-liner for eyes. -jsonl is the machine form. -csv is the archival form. -urls is the re-emission for URL-taking scanners.

The PQ rating view

Problem. A target list’s post-quantum readiness needs a one-line triage: where is the migration stuck? What must move first?

Solution.

pqscan -l estate.txt -jsonl \
  | jq -c '{target:.target.host, group:.handshake.group_name, class:.handshake.group_class, score:.pq.score, label:.pq.label, hndl:.pq.hndl}'

Observation. One compact object per target (live. -o sends the records to the file instead of the pipe. The triage jq reads stdout directly):

{"target":"google.com","group":"X25519MLKEM768","class":"pq_hybrid","score":40,"label":"pq-partial","hndl":false}
{"target":"example.org","group":"X25519MLKEM768","class":"pq_hybrid","score":40,"label":"pq-partial","hndl":false}

Discussion. The record scores itself. pq.score is the weighted rubric sum. pq.label carries the band (pq-ready ≥ 80, pq-ready-hybrid ≥ 60, pq-partial ≥ 40, pq-missing ≥ 1). pq.hndl is the harvest-now-decrypt-later flag. It goes true when an obsolete draft group accepts the handshake. Material intercepted against such a handshake decrypts when the draft breaks. Escalate on hndl. Treat the sub-60 bands as the worklist. The rubric feeds on the negotiated-plane facts today. The certificate signature analytics ride the cert block. pq.score rises through the same field as the deep findings wire in.

The rating in words: -explain

Problem. The text line’s key=value fields are dense. A ticket or a handoff needs the record read in sentences: what the rating means, which layer is the gap. When nothing negotiated, the sentences also read the error class.

Solution.

pqscan -u google.com -explain

Observation. (live) Under each text line, one reading block:

google.com:443 group=X25519MLKEM768 class=pq_hybrid suite=TLS_AES_128_GCM_SHA256 version=tls-1.3 alpn=h2 quic_neg=X25519MLKEM768 quic_class=pq_hybrid cert=ecdsa-with-SHA256/ECDSA pq=pq-partial(40)
  - rating: pq-partial 40/100. The negotiated key exchange is post-quantum. The certificate layer is classical. The lines below carry the detail.
  - key exchange: X25519MLKEM768 (pq_hybrid) — post-quantum hybrid: ML-KEM-768 + X25519. This group protects the session traffic. The harvest-now-decrypt-later risk does not apply while this group negotiates.
  - certificate: classical identity (ECDSA key, ecdsa-with-SHA256 signature). Use a PQ-signed or dual certificate at the next renewal.
  - quic: X25519MLKEM768 (pq_hybrid) on the UDP plane — the same post-quantum rating applies to h3 traffic.

Deep and pq runs add the plan reading. On -mode deep the executed plan reads in operator words: the probe roll-up, the offered-one matrix, the accepted rows (live, pqscan -u google.com -mode deep -explain):

  - deep: the executed standard plan ran 24 probes: 3 accepted, 9 refused with the server's reasons, 12 not-offerable; the offered-one matrix read: group offer set 1/10, quic offer set 1/7, sigalg offer set 0/3, resumption vectors 0/3, hrr probe 1/1; accepted: group/29; quic/h3-exchange; hrr/x25519-shareless — the per-probe detail lives in the jsonl findings.

On -mode pq the reading names the accepted rungs and treats the refusal mass as the expected default:

  - deep: the forced PQ offer set sent 8 offers: 1 accepted, 7 refused with the server's reasons, 0 not-offerable; accepted: group/4588; each forced PQ-only group refused unless the server supports that group — the per-probe detail lives in the jsonl findings.

and a refused dial gets the error-class hint (the record’s zero-populated probe blocks read as the never-ran lines):

  - quic: the lane never ran on this record.
  - error: dial tcp 127.0.0.1:65000: connect: connection refused — the probe reached no handshake. The endpoint is unreachable on this port (closed or filtered). No rating is possible.

Discussion. -explain is a -text-only selector. The machine formats refuse it (exit 64, pqscan: -explain applies to text output only (drop the -jsonl selector)). Every tail the text line carries (cert=sig/key, the deep= counts, ptr=/sni= enrichment) has its counterpart in the reading. The human format tells the same story the machine formats carry.

Self-explanation: -version and the completion scripts

Problem. Support and scripting need the tool’s own identity. They also need shell integration. The generators write them. Hand-maintained flag lists do not.

Solution.

pqscan -version
source <(pqscan -completion bash)     # zsh: pqscan -completion zsh > .../site-functions/_pqscan

Observation. (live) Source builds print the bare stamp. The completion registers under the tool’s name in bash:

pqscan 0.0.1
complete -F _pqscan_completions pqscan

Discussion. The scripts generate from the same flag surface the -h help renders. The surface: every implemented flag, the -mode/-deep -sni/-completion value lists, file completion where a flag takes a path, plus BOTH dash spellings (--word parses anywhere -word does). The completions offer both spellings. Install one-liners and the persisted locations live in INSTALL.md. Re-run the generator after an upgrade. The completion then matches the binary. The -version stamp is the release stamp on packaged builds.

Downstream

Feed nuclei

Problem. Give payload scanning to the tool that reads payloads. Keep the handshake facts on this plane.

Solution.

pqscan -l assets.txt -urls | nuclei -silent -tags tls

Observation. -urls emits scheme://host:port per target: the URL list any URL-taking scanner needs. The handshake/certificate/PQ facts stay in pqscan’s record stream (the -o file of the same run).

Discussion. The pipeline’s stages compose by contract. Records stream in input order. The WAL rests beside them. Each consumer reads independently. The reverse also holds: masscan/naabu’s output feeds IN. nuclei’s output feeds OUT. pqscan never inspects payloads.

The PQ calendar

Problem. Readiness is a trajectory, not a snapshot. The reader needs the week-over-week delta, not the raw JSON mass.

Solution.

# nightly, cron'd; the same target list, deterministic input order
pqscan -l estate.txt -csv -o /var/pqscan/$(date +%F).csv
diff /var/pqscan/yesterday.csv /var/pqscan/today.csv > /var/pqscan/pq-movement.diff

Observation. Repeat runs against a fixed list are comparable. Input order and first-occurrence dedupe keep the stream deterministic. The ts column orders the movement.

Discussion. Keep the diffs, not the runs. A day’s movement is a handful of rows: who moved up a band, who regressed, whose hndl still fires. A CI job on the same contract gates merges in the target set’s own repos. A band-demotion PR surfaces there as a failing parity check.

Reverse-DNS workflow — IP ranges in, domains out

Problem. An IP sweep loses the SNI a domain run carries. Certificate analytics without hostname context lose meaning on shared infrastructure.

Solution.

pqscan -u 8.8.8.8:443 -ptr -sni auto -certs-dir certs/ -jsonl

Observation. The literal resolves pre-probe. The record carries the answer verbatim. The PTR-derived candidate becomes the SNI. The record’s ptr and sni_used both fill. The PEM export leaves the leaf for downstream tools:

{"schema":1,"mode":"fast","target":{"input":"8.8.8.8:443","host":"8.8.8.8","ip":"","port":443,"ptr":"dns.google","sni_used":"dns.google"},"handshake":{"negotiated":4588,"group_name":"X25519MLKEM768","group_class":"pq_hybrid","suite":"TLS_AES_128_GCM_SHA256","version":"tls-1.3","alpn_neg":"h2"},"quic":{"attempted":true,"state":"negotiated","negotiated":4588,"group_name":"X25519MLKEM768","group_class":"pq_hybrid","discovered":{"alt_svc_h3":true,"h3_direct":true}},"cert":{"sig_alg":"ecdsa-with-SHA256","sig_pq":false,"key_alg":"ECDSA","key_pq":false,"dual":false},"pq":{"score":40,"label":"pq-partial","hndl":false,"summary":"negotiated_pq_hybrid(+40)"},"error":""}

Discussion. -ptr builds the run’s pool off the system resolver (bounded: 4 workers, a 2s budget, a 1024-entry LRU of positives). Misses never cache. -sni sets the candidate rule: auto/ptr-then-ip prefer the PTR name and fall back to the literal. ip-only always takes the literal. The record’s sni_used carries the candidate actually used. -certs-dir writes the scan’s end-entity certificate per host (<dir>/<host>.pem). The scan then feeds a CA/policy pass without a re-dial. A literal whose PTR does not resolve scans on the literal. The miss records as an empty ptr. Enrichment is never a blocker. Bulk PTR enumeration at scale stays with massdns or zdns. Pre-filter there; hand the FQDNs forward.

The flag map (live, landing, planned)

Recipe topic Flags / mode State
one handshake, both planes -u, -l and -l - (the stdin-file alias), the implicit piped stdin, – (the bare marker), -jsonl or -json, -csv, -urls, -o, -resume, -quic-always, -quic-off, -debug, -debug-out live
deep modes -mode deep with -deep standard|full live
forced PQ ladder -mode pq live
the verdict in words -explain (under -text; the machine formats refuse it) live
self-explanation -version, -completion bash|zsh live
rate/window/breakers -rate, -threads live
import adapters -from-httpx, -from-naabu, -from-nuclei (one pinned capture command each, version-stamped in the recipes) live
sharding -shard i/n live
identity hooks -ptr, -sni, -certs-dir live
port selection -p planned
profile config -config, PQSCAN_* env planned
legacy module -legacy heartbleed,ccs,robot planned
probe overrides -probe-suite, -probe-group, -probe-sigalgs, -probe-version planned
TLS fingerprints -client-fingerprint, -jarm planned
sort/fields/silent/stats -sorted, -fields, -silent, -stats planned
dial budgets -timeout, -connect-timeout, -retries planned
scale ceilings -c, -dns-threads planned
the HTTP probe -http planned
CIDR expansion -cidr and CIDR spec lines (per-address expansion is not supported; tracked for follow-up) planned

The usage-drift gate in CI keeps USAGE.md and this map honest: a flag without a man-page row fails the PR. A row outside the implemented+planned set fails the PR. The flag map is where a new operator starts.