Skip to main content

qrp-mcp

A local cryptographic inventory for developers and AI agents that carries verifiable coverage and its own limits inside the CBOM.

Every signature in your wallet, contract and validator rests on elliptic-curve cryptography, and a large quantum computer breaks it. Plenty of tools will tell you what they found. This one also tells you what it read, what it could not read, and which question it is not answering — in the document itself, where an auditor can check it rather than take your word.

An MCP server that scans a local directory for cryptography that Shor's algorithm defeats — secp256k1, Ed25519, BLS, Schnorr, RSA — plus weak primitives and CI signing commands, and classifies each one: broken by a quantum computer, post-quantum, or neither.

Everything runs on your machine. No network calls, no account, no API key, nothing uploaded. A tool that reads your keys' surroundings has no business phoning home, so this one makes zero outbound connections — enforced by a test, not promised in a paragraph. The only process it starts is a local git, to pin what it read, with the scanned repository's own hooks and filters disarmed.

Why this matters for chains and wallets

Bitcoin and Ethereum authenticate with ECDSA over secp256k1. Solana, Cardano and Polkadot use Ed25519. Ethereum's consensus layer aggregates with BLS12-381. Taproot adds Schnorr.

All four are public-key schemes whose security rests on discrete-log hardness — and all four fall to the same quantum algorithm. The practical consequence is specific: once a public key is exposed, the private key becomes derivable. Reused addresses, on-chain public keys, and long-lived validator keys are where that exposure already exists today.

None of this is a prediction about dates. It is an inventory question: which of my code paths sign with what? That question has an answer right now, and this tool gives it.

Quick start

Add it to your MCP client — no installation step, uvx fetches and runs it:

{
  "mcpServers": {
    "qrp": {
      "command": "uvx",
      "args": ["qrp-mcp"]
    }
  }
}

Then ask your agent:

Scan ~/code/my-protocol for quantum-vulnerable cryptography.

As a Claude Code plugin

The same server, packaged with a skill, so there is no config file to edit:

/plugin marketplace add StanimirTenev/qrp-mcp
/plugin install qrp@quantumreadiness

Then /qrp:pqc-scan in any project. Both routes need uv on your PATH, since uvx is what fetches and runs the server.

Without an agent: write the result to a file

uvx qrp-mcp scan ~/code/my-protocol --out result.json

This produces the same result as the scan_repo tool, written to a file you can read before it goes anywhere. Nothing is sent. --level masked stars out everything in the quoted line but the algorithm name, and --level trimmed removes the line altogether; both keep each file and line number. The SHA-256 of the written bytes is printed, so anyone you send the file to can quote back exactly what they received.

uvx qrp-mcp cbom ~/code/my-protocol --out cbom.json
uvx qrp-mcp closure before.json after.json --out closure.json

cbom writes the same CycloneDX document as the export_cbom tool, masked by default because it is the document built to be sent. closure writes the prove_closure verdict for two saved scans, and names both inputs by their SHA-256, so the three files can be tied together by whoever receives them. Both print the SHA-256 of what they wrote.

Tools

Tool What it does
scan_repo(path, level, profile) Scans a directory's source, CI/CD configs and infrastructure-as-code; returns findings, a summary, and the coverage block below
export_cbom(path, level) The same reading as a CycloneDX 1.6 CBOM, with the coverage block inside it
compare_coverage(first, second) Whether two scans produced numbers that can be compared at all
prove_closure(before, after) Which findings a change actually closed, from two saved scans of one tree — only if the two runs can be compared for it
list_algorithms() The algorithm families the server recognises and how each is classified

What it looks at

Chain and wallet code — secp256k1, ecrecover, ethers, web3, bitcoinjs, ECPair, btcec, tweetnacl, @solana/web3.js, solana_program, bls12-381, blst, @chainsafe/bls, BIP340/Taproot Schnorr. Solidity (.sol), Rust (.rs), Move and Cairo are scanned alongside Python, Go, Java, Kotlin, Scala, Groovy, JS/TS, Ruby, PHP, C/C++/C# — headers included — PowerShell, Perl and shell.

Classical crypto anywhere else — RSA, DSA, DH, ECDSA and elliptic-curve usage, plus MD5, SHA-1, RC4 and DES/3DES. Not only through library calls: the names the protocols themselves use (ssh-rsa, rsa-sha2-512, ssh-dss, key types such as rsa-2048 and RSA_4096), the modern OpenSSL 3 form where the algorithm is a string argument (EVP_PKEY_Q_keygen(libctx, propq, "RSA", bits), the EVP_*_fetch calls), and the hash idioms people actually write (hashes.MD5(), hashlib.new('md5'), MD5Init, <sha1.h>, Go sha1.Sum). Since 0.26.0 also the classes a key is loaded through (jose.JWKRSA.load(...), jose.ComparableRSAKey(...)) and the signature OIDs of Python's cryptography (SignatureAlgorithmOID.RSA_WITH_SHA1): certbot creates its ACME account key as jose.JWKRSA(...), and that line, with 28 more like it, was no finding.

Since 0.27.0 also RSA without padding -- RSA_private_encrypt, RSA_public_decrypt, RSA_padding_add_none, RSA_NO_PADDING, the raw oracle the 2026 forgery of 1024-bit signatures needs (ePrint 2026/2131); until then the four were found only when something else on the line named RSA. And the type and enum vocabularies of the libraries that carry the algorithm in a name: Java's RSAPublicKeySpec, DSAPrivateKey, DSAParams; auth0's Algorithm.RSA256; the WebCrypto and JOSE names "RSASSA-PKCS1-v1_5" and "RSA-PSS"; Mbed TLS's mbedtls_rsa_* and MBEDTLS_PK_RSA; OPENSSL_KEYTYPE_RSA, TPM_ALG_RSA*, x509.DSAWithSHA256, KeyType.RSA; Go's *rsa.PublicKey; .NET's RSASignaturePadding.Pss; and a JWK's "kty": "RSA". These are case-sensitive: rsaKey is a variable, RSAKey is a type.

From 0.32.0 also OpenSSL's RSA signing calls — RSA_sign, RSA_verify, their _ASN1_OCTET_STRING forms, RSA_verify_PKCS1_PSS(_mgf1), RSA_padding_add/check_PKCS1_PSS, _PKCS1_type_1, _X931 and RSA_PKCS1_PSS_PADDING (names from include/openssl/rsa.h), with role signature. A secure-boot verifier is often one RSA_verify against a key fused at manufacture, and that line scanned as nothing (raised by Ken Camp).

DNSSEC (from 0.32.0, raised by Georgi Ivanov). The algorithm a zone is signed with, by its IANA mnemonic as BIND (dnssec-policy … algorithm rsasha256 2048, dnssec-keygen -a RSASHA256 -b 2048), Knot (algorithm: rsasha512) and PowerDNS (default-ksk-algorithm=rsasha1-nsec3-sha1) write it: RSAMD5, RSASHA1, RSASHA1-NSEC3-SHA1, RSASHA256, RSASHA512 → RSA, with the size where the line gives one; DSA-NSEC3-SHA1 → DSA; ECDSAP256SHA256, ECDSAP384SHA384 → ECDSA with curve P-256 / P-384; ED25519, ED448, MLDSA44 by their own rules. And by number (IANA DNSSEC Algorithm Numbers, read 22 Oct 2026), only in the shape of a record — DNSKEY/CDNSKEY flags 3 alg, DS/CDS tag alg digest-type digest, RRSIG type alg, and BIND's static-key/initial-key/static-ds/initial-ds trust anchors: 1, 5, 7, 8, 10 RSA; 2 DH; 3, 6 DSA; 13, 14 ECDSA with the curve; 15 Ed25519; 16 Ed448; 17 SM2; 18 ML-DSA. A bare 8 is a number, not RSA. ECC-GOST (12, 23) has no family here and is left unnamed. .zone files are read. Not read: PowerDNS's ecdsa256/ecdsa384 as DNSSEC names — they are found as ECDSA by the ordinary rule, without a curve, because the same word is a variable name in OpenSSH; a bare DSA (algorithm 3) as a mnemonic; the .private files dnssec-keygen writes; zone files saved under other names (db.example).

Where they came from, and what they did. An independent labeller read 34,967 chunks of 185 public repositories; 45 of them were set aside before labelling and not read while the rules were written. Chunks where the labeller sees an algorithm named in the text and the scanner reports nothing of that family: 1,068 → 967 (-9%) on the 140 the rules were written from, 167 → 155 (-7%) on the 45 they had never seen. The two move together, so the rules carry over rather than fit; both are modest, because most of what remains is a bare word or a name without an operation, which this tool does not count on purpose. The labeller is a model, not ground truth: these are counts of disagreement, not recall.

Cipher suites, decomposed — ECDHE-RSA-AES128-GCM-SHA256 is ECDH and RSA, and DHE-DSS-… and DES-CBC3-SHA name families that reading the suite as one word never sees. IANA TLS_* names are read the same way. A banned component is not a use: !MD5 and !3DES in a cipher list are exclusions, and they are treated as such.

Protocols and dependencies, counted without inventing an algorithm — a pinned TLS version (MinVersion: tls.VersionTLS12, ssl_protocols, SslProtocols.Tls12, SSL3_VERSION), an SSH transport line, and a cryptographic library declared in package.json, go.mod, requirements.txt, Cargo.toml, pom.xml or a Gemfile. These are real facts about a repository and they are reported — in their own buckets, never in detected_algorithms. Each carries a basis: configured_protocol or declared_dependency, never observed_call, because an installed library is not a line of code that calls it. A version is not a verdict either: TLSv1.0 is marked deprecated, and quantum vulnerability is not claimed from a version number, since TLS 1.3 is vulnerable over X25519 and is not over X25519MLKEM768. -SSLv3 in an SSLProtocol line is a ban, and is recorded as one. Manifests are parsed structurally, so a library named in a comment is not a dependency.

Key sizes — a size named on the line (key_size=1024, rsa:1024, genrsa 1024, GenerateKey(..., 1024)) travels with the family, so a weak RSA key is reported as weak rather than as one more RSA. algorithm_key_sizes holds the smallest size seen per family — the figure that answers is anything weak here — and algorithm_key_sizes_observed holds every size seen, which is a different question and became the operational one when RSA-896 was factored on a data-centre fleet: where is RSA-1024 still in use?

From 0.17.0 the size reaches the CBOM component. Where one size was observed it is stated as cryptoProperties.algorithmProperties.parameterSetIdentifier — the field whose own example in the schema is a key length. Where several were, that field is left out and all of them are named in qrp:observedKeySizes instead: choosing one would put a number in the document that looks measured and is not, and a reader could not tell the difference.

Hybrids and composites — the RFC 10024 TLS groups X25519MLKEM768, SecP256r1MLKEM768 and SecP384r1MLKEM1024, OpenSSH 10's default mlkem768x25519-sha256, and the composite certificate algorithms of draft-ietf-lamps-pq-composite-sigs such as id-MLDSA44-RSA2048-PSS-SHA256. A hybrid holds if either half holds, so the post-quantum scheme leads — and the classical half is carried in also_present rather than dropped, since it is the component Shor breaks.

Post-quantum schemes, by family — ML-KEM, ML-DSA, SLH-DSA, Falcon (FN-DSA), NTRU, Classic McEliece, BIKE, HQC, FrodoKEM, XMSS, and the stateful LMS/HSS of SP 800-208 that CNSA 2.0 requires for firmware signing. The nine schemes NIST advanced to its third additional-signatures round in May 2026 — FAEST, HAWK, MAYO, MQOM, QR-UOV, SDitH, SNOVA, SQIsign, UOV — are recognised, the eight other than HAWK as candidates and HAWK as withdrawn (below), and CROSS as dropped from that process.

Each carries the mathematical family it rests on (structured or unstructured lattice, code-based, hash-based, isogeny-based, multivariate, symmetric-based) and where it stands: standardised, selected, candidate, superseded, research, withdrawn, eliminated or broken. SIKE is reported as broken and HAWK as withdrawn rather than counted as quantum-resistant — "post-quantum" is a category, not an assessment.

Only a published standard is pqc_ready. A selected or candidate scheme — HQC, Falcon (FIPS 206 not yet listed as published here), Classic McEliece, BIKE, FrodoKEM, NTRU including OpenSSH's sntrup761, and the additional-signature candidates — is pqc_pre_standard: found, post-quantum, and not counted as ready, because an implementation of today's specification need not match the standard when it comes (FIPS 203's own 2023 draft differs from the final text). Until 0.28.0 these counted as pqc_ready; raised in public by Bill Buchanan about HQC.

Round-3 Kyber is not ML-KEM. FIPS 203, Appendix C: ML-KEM uses a different Fujisaki-Okamoto variant from the round-3 submission, and the two do not interoperate. Until 0.28.0 every Kyber spelling was reported as standardised ML-KEM (raised by Mehrdad Daei). Now the identifiers round-3 code carries — pqcrystals_kyber…, crypto_kem_…kyber…, PQCLEAN_KYBER…, pqcrypto.kem.kyber… and the pqcrypto-kyber crate, OQS_KEM_alg_kyber_…, CIRCL's kem/kyber, pqc_kyber, and the draft hybrids such as X25519Kyber768Draft00 — are family Kyber, status superseded, pqc_pre_standard. The word kyber alone is reported the same way: it is the round-3 name and also an old name people still use for ML-KEM, the line does not say which, and claiming a standard for code that may not interoperate with it is the worse error. A line that names ML-KEM as well (ML-KEM (Kyber)) resolves the old name and is ML-KEM only. Whether code that says pqcrystals_kyber was updated to FIPS 203 under the old name is not visible from the name, and the finding says so.

OpenSSH's experimental ML-DSA key is not the one that replaced it. OpenSSH 10.4 shipped a composite of ML-DSA-44 and Ed25519 as an experiment, as ssh-mldsa44-ed25519@openssh.com; 10.6 enabled the scheme without the suffix, and its release notes say keys generated with the experimental support must be regenerated and/or removed. Until 0.30.0 both read as ML-DSA, pqc_ready. Now the suffixed type — in a key or certificate file, or named in configuration such as HostKeyAlgorithms — is family ML-DSA-44-Ed25519 experimental, status superseded, pqc_pre_standard, and the ML-DSA rule does not count it a second time. The unsuffixed composite stays ML-DSA and now names its classical half: also_present: ["Ed25519"]. Whether a given server still accepts an experimental key is not visible from the file. On OpenSSH's own tree, four test keys under regress/ still carry the experimental type while the source uses the new name.

The summary does not reassure past a broken scheme. summary.pqc_readiness is one of broken_pqc_present (a broken, withdrawn or eliminated post-quantum scheme is present — first, whatever sits beside it), hybrid_partial, hybrid_pre_standard (classical beside post-quantum schemes none of which is standardised), mechanism_protected, classical_only, pqc_pre_standard, pqc_ready (every post-quantum scheme found is standardised and nothing classical is), no_quantum_vulnerable_detected, not_established (one of the reassuring values, withheld because the scan did not read or determine everything — from 0.34.0) or unknown. Until 0.28.0 a file holding only SIKE read no_quantum_vulnerable_detected (raised by Larisa Ghazaryan). pqc_pre_standard_count and broken_pqc_count sit beside pqc_ready_count, and list_algorithms answers with the same classification a scan gives. From 0.35.0 summary.by_role gives the same value for key establishment and for signatures apart, with the families in each (none_found for a part with none).

Which module implements it, where the file says. Validation belongs to a module, not to an algorithm, and @noble/post-quantum and Go's crypto/mlkem used to give identical findings (raised by Jesone Sam). Now a line that uses a family carries provider when the file names the module unambiguously — @noble/post-quantum, the Go standard library ("crypto/mlkem"), Cloudflare CIRCL, liboqs, pqcrypto, PQClean, the pq-crystals reference code, Bouncy Castle, BoringSSL (MLKEM768_…, MLDSA65_…, KYBER_…) and OpenSSL 3 (EVP_PKEY_Q_keygen, EVP_PKEY_CTX_new_from_name, EVP_KEM_fetch…) — and each finding lists its providers. The module is usually named on the import line and used on another, so a call line takes the module its file imports for that family; two modules in one file and none on the line leave it absent. An unknown provider is absent, never guessed: a header such as <openssl/evp.h> is shared by OpenSSL, BoringSSL and LibreSSL and names no one. What is named is the API, not the build — a fork that keeps the API reads the same — and nothing here claims CMVP or FIPS 140-3 validation.

From 0.32.0 (raised by Paul J. Lilly, extending a point Solahuddin Shamsuddin made): Python's cryptography is a provider too (pyca/cryptography, by from cryptography.hazmat…), and a version pinned in a manifest is joined to the provider whose package it is: cryptography==44.0.0 in requirements.txt gives every line that imports it provider_pin: {"package": "cryptography", "version": "44.0.0", "pinned_in": "requirements.txt:1"}, and the finding provider_pins. Read from requirements.txt (== only) and go.mod, from the nearest manifest above the file, for pyca/cryptography, Cloudflare CIRCL and pqcrypto. A range (>=42) is a request, not a version, and gives nothing; two pins that disagree at the same level give nothing. pyproject.toml, Pipfile and the lockfiles are not read for this. "Pinned" is what the manifest asks for, not what is installed.

And OpenSSL's FIPS provider, activated in openssl.cnf, is evidence.fips_provider, one item per line that brings it in, from OpenSSL's config(5) and fips_module(7): .include …/fipsmodule.cnf, fips = <section> in the section providers = names (when that section's activate is yes/on/true/1, or the section is in the included file), and default_properties = fips=yes or the deprecated fips_mode = yes. Each says "FIPS provider activated in openssl.cnf:8 … Activated in configuration, not validated". Every finding whose provider is OpenSSL 3 then carries fips_provider_configured_in — the same ML-KEM line read "OpenSSL 3" whether it ran through the FIPS provider or the default one. Which process loads which configuration file is not in the files, and is not claimed; neither is validation.

Isogeny key exchange in no standardisation process: MIKE, CSIDH, CTIDH. Until this release code doing either read as having no post-quantum cryptography at all (raised by John Preuß Mattsson). Matched by the implementations' own identifiers — MIKE's mike_keypair( / mike_exchange(, MIKE_BUILD_TYPE and the other CMake options, its six primes (p308_633 …), the mike_rs crate, from mike.mike import, tensor-MIKE/mike_*; CSIDH/CTIDH as csidh, ctidh, highctidh, secsidh, CIRCL's dh/csidh — and reported with status research, pqc_pre_standard: not ready, and not broken. The SIKE attack does not carry over (it needs the torsion-point images MIKE and CSIDH do not publish). CSIDH-512 is below its claimed level against quantum attack (Peikert; Bonnetain–Schrottenloher), which the finding says — a cost estimate, not a classical break. The word mike alone never matches: it is a person, the MkDocs versioning tool on PyPI, and MIKEY (RFC 3830); import mike cannot be told apart from MkDocs and is not read as MIKE either. Through the classifier API MIKE matches only as the whole value.

Merkle Tree Certificates are named, never counted as post-quantum. The three OIDs IANA assigned on 28 Sep 2026 (1.3.6.1.5.5.7.6.67, .1.38, .25.3), Cloudflare's experimental arc 1.3.6.1.4.1.44363.47 (in dotted and Go integer-list form), the draft's ASN.1 names (id-alg-mtcProof, MTCProof, MTCLogEntry, MerkleTreeCertEntry …) and the BoringSSL and Chrome identifiers (X509_V_FLAG_USE_MTC_DRAFT_PLANTS_05, NID_alg_mtcProof_draft, MTCAnchor, kVerifyMTCs, verify-mtcs) give family MTC, classification unknown. They live in CA, TLS-library and browser code, not in an origin's configuration, and a hit means "this code issues or verifies MTCs" — the authentication rests on the CA's and the cosigners' signature algorithms, and the leaf keeps an ordinary key (ECDSA-P256 in Cloudflare's experiment). So it is neither pqc_ready nor a mechanism, and stays out of pqc_readiness. Trust Anchor IDs (TLSEXT_TYPE_trust_anchors, 0xca34) are a prerequisite, not MTC, and are not matched; nor is merkle alone. Raised by Delta Li.

Certificates and keys — .pem, .der, .crt, .cer, .cert, .csr, .key, .pub, .p12, .pfx, and from 0.35.0 the Java keystores .jks and .keystore. Algorithms are resolved from the object identifiers inside the DER and from PEM labels and OpenSSH key types, and private key material is reported separately. This does not parse X.509: it matches the object identifiers already in the classifier against the bytes. A file that is not a certificate but contains an identifier's bytes is reported, and the finding says so — "observed in the file; the file was not decoded as a certificate". An earlier version of this paragraph promised that a malformed certificate yields nothing; an independent analysis put a valid RSA identifier into arbitrary bytes and got a finding, which is what the code measures. For a certificate register — issuer, validity, the device — this tool is the wrong instrument and says so: that data comes from a structural parser or an external inventory.

From 0.32.0 an EC key's curve is named from its named-curve identifier — P-256 (1.2.840.10045.3.1.7), P-384 (1.3.132.0.34), P-521 (1.3.132.0.35) from RFC 5480 §2.1.1.1, secp256k1 (1.3.132.0.10) from SEC 2 v2.0 — as curve and key_size on the EC finding. Before, the curve identifier was swallowed by id-ecPublicKey, and a P-384 key read as "EC".

From 0.33.0 an RSA key's size is read the same way, from its public key: the modulus in a SubjectPublicKeyInfo whose algorithm is rsaEncryption (RFC 3279 §2.3.1, RFC 8017 A.1.1) and in an RSA PUBLIC KEY block, as key_size on the RSA finding, so it reaches algorithm_key_sizes and the CBOM. Before, a certificate's RSA key was named and never sized. An issuer's RSA signature on a certificate whose own key is EC gets no size — the issuer's key is not in it — and a private key is not read for its size. On certbot the smallest RSA size becomes 512 bits (test certificates in acme/…/testdata, checked with openssl x509 -text); on OpenSSH nothing moves.

And a private key recoverable from its public key is a finding of its own, under evidence.weak_keys ("private key recoverable"), with the curve and the scalar: every uncompressed EC public point the scan can locate is compared with k·G for every k ≤ 256. The eight keys in a post of 22 Oct 2026 all had k < 256, one of them 4·G on P-384, and before this release that certificate scanned exactly like one with a random key (raised by Bill Buchanan; the check is Israr Ahmed's). Where the point is located, without parsing X.509: a DER BIT STRING 00 04 X Y of the key's curve size (SubjectPublicKeyInfo in a certificate, a public key, SEC 1 EC PRIVATE KEY, PKCS#8) and OpenSSH's nistp256/384/521 encoding (a .pub line, an openssh-key-v1 private key file). P-256, P-384, P-521 and secp256k1; curve parameters from SEC 2 v2.0, agreeing with RFC 5903 §3. Not checked: compressed points, keys inside encrypted containers (PKCS#12, encrypted PEM), other curves (brainpool, Ed25519 and X25519 keys), points quoted in source code or in a known_hosts/authorized_keys line read as configuration, and any scalar above 256 — the bound is the finding's, and a key with scalar 257 is not reported. Debian's weak OpenSSL keys and ROCA are a different family of weak key and are not checked. The table is built once per curve (four curves: 39 ms) and a key costs a lookup: on OpenSSH's 130 key and certificate files the check took 3 ms. In the CBOM each weak key is a related-crypto-material component, EC key on P-384 with private scalar 4.

Configuration — nginx.conf, sshd_config, openssl.cnf, swanctl.conf, .ini, .toml, .properties, .hcl, .json, and any YAML that is not a manifest. This is where a TLS or SSH hybrid group is chosen: X25519MLKEM768 and mlkem768x25519-sha256 are almost never strings in code. IKE proposal syntax is read here too — ecp384 is NIST P-384, modp2048 is group 14. From 0.32.0 also .zone files, and the DNS size budget of a BIND configuration: max-udp-size (the largest UDP response BIND sends) and edns-udp-size (the buffer it advertises for responses it receives) are protocol assets with protocol: "dns", udp_setting and udp_size. Both default to 1232 bytes (BIND 9 ARM); a file with a dnssec-policy that does not set max-udp-size gets that default as an asset with default_applied: true, at the dnssec-policy line.

OPC UA SecurityPolicies. Each of the 30 SecurityPolicy URIs in the OPC Foundation profile database (UACore 1.05, read 7 Oct 2026) names the algorithms it rests on — RSA, ECDSA+ECDH, Ed25519/X25519, Ed448/X448 or RSA with finite-field DH; SHA-1 for Basic128Rsa15 and Basic256 — and no post-quantum one exists. A URI (http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256) or a stack's own identifier — .NET SecurityPolicies.X, open62541 UA_SecurityPolicy_X( / UA_ServerConfig_addSecurityPolicyX(, asyncua SecurityPolicyType.X_Sign… and set_security_string("X,…, Milo/node-opcua SecurityPolicy.X, gopcua SecurityPolicyURIX, Node-RED "secpol" — gives those families, and the policy itself as a protocol asset (protocol: "opcua") with policy_status and deprecated: ten are Deprecated in the database (Basic128Rsa15, Basic256, the six un-suffixed ECC policies and two interim names). For the six ECC ones the .NET stack disagrees (IsDeprecated = false) and the asset says so; a policy without the attribute is "not marked deprecated", not "released". Stack-only names (Https, Basic192 …) are named with no algorithm claimed. A bare Basic256Sha256 or None never matches. .xml and .json5 are read only when the file contains a SecurityPolicy URI — a .NET *.Config.xml, an open62541 *.json5 — and declared as claimed_types.config_when_naming_opcua_security_policy; every other XML stays type_not_claimed, so coverage on trees without OPC UA does not move. Raised by Ogochukwu Friday Ikwuogu.

AWS load-balancer TLS policies, and the one you get by writing none. An ELBSecurityPolicy-… name — Terraform ssl_policy, CloudFormation SslPolicy, CDK elbv2.SslPolicy.X — is read against the 45 policies on AWS's ALB and NLB pages: every one offers classical (EC)DHE (ECDH), the 13 …PQ-2025-09 ones add hybrid ML-KEM, and those with RSA key-transport suites name RSA; the policy is a protocol asset with allows_versions, post_quantum and deprecated (TLS 1.0 or 1.1 allowed). A Terraform aws_lb_listener/aws_alb_listener or a CloudFormation AWS::ElasticLoadBalancingV2::Listener that serves HTTPS or TLS and names no policy gets ELBSecurityPolicy-2016-08, AWS's documented default for the CLI, CloudFormation, the CDK and the API (Terraform sends none) — TLS 1.0–1.2, RSA key transport, no post-quantum group — with default_applied: "default applied because ssl_policy is absent", basis: provider_default and evidence kind default. The console default is a PQ policy; that difference is the point (raised by Vladimir Mikhalev, whose probe — explicit 2016-08, and no ssl_policy — gave 0 findings both ways until this release). Only a top-level protocol counts: the redirect { protocol = "HTTPS" } inside an HTTP listener does not make it an HTTPS listener, and ssl_policy = var.x is not absent. An unknown name, and CDK LEGACY (on neither AWS page), claim nothing.

IKEv2 fragmentation, where the configuration carries post-quantum. An ML-DSA signature lands in IKE_AUTH, and draft-ietf-ipsecme-ikev2-pqc-auth makes RFC 7383 fragmentation a MUST for it; RFC 9370 puts additional key exchanges in IKE_INTERMEDIATE so they can be fragmented. So where a strongSwan, Libreswan, Cisco, FortiOS, Junos or OpenBSD iked configuration carries a post-quantum family, a protocol asset (protocol: "ike") names fragmentation — enabled, disabled, the value as written, or not_found in the files read — and fragmentation_default as each product documents it: strongSwan yes since 5.5.1, Libreswan yes (5.4), FortiOS enable (7.6.6), Junos enabled, iked nofragmentation. A file rarely carries the version, so a default is said as documented, never as what the box does. Cisco's is "default not confirmed": IOS XE and ASA document opposite defaults and the platform is not in the file. FortiOS's numeric set addkeN 36 is read as ML-KEM (35–37), and 1080–1094 as Kyber, FrodoKEM, BIKE and HQC. Raised by Saqib Ahmad.

Quantum-resistant mechanisms, not only algorithms — RFC 8784 mixes a postquantum preshared key into IKEv2 key derivation, so a tunnel resists a quantum adversary with no post-quantum algorithm present. A scanner matching algorithm names cannot see that by construction, and would report a protected deployment as classical_only. PPK is matched by the directives that switch it on and classified as quantum_resistant_mechanism — deliberately not pqc_ready, because a preshared key is not ML-KEM. Whether it holds depends on the entropy of the key and on out-of-band distribution, neither of which is visible in a file, and the finding says so.

CI/CD pipelines — signing commands such as gpg --sign, cosign sign, signtool, jarsigner, codesign. From 0.32.0 also Secure Boot, kernel-module and IMA/EVM signing, each from its own manual: sbsign, pesign -s/--sign, kmodsign, evmctl sign/ima_sign/ sign_hash and the kernel's scripts/sign-file; and signing commands in any .sh file, not only in the pipeline files — a pipeline often only calls the release script. A step that names its key (openssl dgst -sign KEY, -inkey KEY, --key KEY, signtool's /f FILE, the key argument of kmodsign and sign-file) carries signing_key: the path, in_tree — whether the scan read a key or certificate file there, from the tree's root or the script's directory — and the families it found in it. A path written with a variable is left alone. In the CBOM the steps are still only counted. Raised by Ken Camp. Whether a trust anchor can be rotated in the field is not in any file and is not inferred: that is the platform owner's statement.

Infrastructure as code — Terraform and Kubernetes key algorithms, and private key material committed by mistake.

Build files, includes, test recipes and key inventories — Makefile, CMakeLists.txt, .in, .cmake (a build file enumerates which algorithms a tree implements at all), .inc (the assembly and C fragments of implementations — OpenSSL keeps its ML-DSA there), .t (Perl test recipes: tests are code, and skipping them quietly is exactly what this tool argues against), and known_hosts / authorized_keys, where the key type is named on every line.

Not read, on purpose — documentation: .txt, .md, .pod, .rst. Measured across five real repositories, reading them would have added more than 11,000 "findings" from help texts and changelogs. An algorithm mentioned in prose is not a deployment. The boundary is pinned by a test, so it is a declared scope rather than a silent skip — the same standard this tool asks of others. Binaries and images are not read either.

Accidents of the alphabet are not findings — a match inside a long hexadecimal or base64 run does not count. A NIST test vector in OpenSSH spells ed448 inside its message bytes.

Real run against OpenZeppelin's contracts (711 files, about five seconds):

{
  "detected_algorithms": ["ECDSA", "RSA"],
  "summary": {
    "quantum_vulnerable_count": 2,
    "pqc_ready_count": 0,
    "highest_severity": "high",
    "pqc_readiness": "classical_only"
  }
}

Very long lines

A test-vector line can carry tens of thousands of hex digits. Until 0.25.0 every pattern searched through such a run before the match was thrown away as sitting inside a blob, and the cost grew with the square of the run's length: one 9,272-digit ML-DSA vector took 2.8 s for the 48 patterns, and a scan of BoringSSL (12,921 lines over 2,000 characters) did not finish. Now the inside of a blob is blanked before searching — positions and the blob's edge characters stay — and BoringSSL takes 18 minutes. On nine corpora (certbot, OpenSSH, Vault, OpenSSL, curl, Mbed TLS, wolfSSL, s2n-tls, rustls) every piece of evidence is identical before and after.

What the coverage block says

Every scan carries one, because a coverage figure without its conditions is not comparable to another coverage figure. Four things, each answering something the percentage cannot:

  • instrument — the version and the emitter's own commit. Two runs of this package once reported the same version from code that differed by a commit, so the version alone does not identify what did the reading. Where the tool runs from an installed wheel there is no commit, and the field says which absence rather than going quiet.
  • corpus — the commit that was read, whether the tree was dirty, and whether the clone was shallow. A commit identifies a tracked tree; a scanner walks a filesystem, and the two are not the same thing.
  • window — when it was read.
  • scope — the denominator, the numerator, and every file that was in the first and not the second, with a reason. The reasons are a closed set: type_not_claimed is a boundary this tool declares, unreadable is a failure it hit, and they are never collapsed. A reason with no instances is reported at zero rather than omitted. From 0.27.3 every reason that carries a count also carries the paths it counts, type_not_claimed included: a count alone is not an accounting (R1 of the Coverage Attestation Profile, draft-hillier-coverage-attestation-00). On certbot that names 379 files that were until then only a number by extension. In the CBOM each list of names is one property holding a JSON array (["a,b.c", "d.c"], [] when empty) from 0.28.0. Until then the names were joined by commas, and switched to one indexed property per name when any name held a comma: nothing was lost, but where the names sat depended on what they were called. Lists of objects (linked entries) stay indexed field by field. The declared boundary (instrument.claimed_types) names every way a file is read. Until 0.29.1 it left out .yaml/.yml, the dependency manifests (requirements.txt, pom.xml, packages.config, *.csproj…) and a file of any type that begins with key material: a CAP-1 examiner run by Joel Hillier over the certbot CBOM counted 44 such files examined outside the declared types. They were read all along; only the declaration was narrower than the scan.

Measured across five real repositories (certbot, OpenSSH, Vault, Bitcoin, OpenSSL) at this release, the scan reads 67% of the files present — 74% of OpenSSL, 85% of OpenSSH, 66% of certbot, 77% of Bitcoin, 58% of Vault. The rest is counted and named with a reason. A directory that cannot be entered or listed is reported in unreadable_directories; its files cannot be counted, so the scan then says it cannot account for every file instead of claiming it read them all.

It also states which kind of claim the numbers are. Coverage is a claim about reading, not about finding: a file can be opened, counted, and still be one this tool was blind in. Reaching a file is something a scanner can measure about itself; whether it found what was there is not, because a silent rule and an absent algorithm produce the same output. So the block reports claims.axis: reached, and declares that it holds no control — the corpus with independently established contents that would license the second claim — naming the absence rather than implying the stronger reading.

What the number is, and what it is not

A coverage figure here is the overlap between the file types this tool claims and what the corpus is made of. It is not a discovery rate. A scanner claiming 32 extensions cannot reach 100 % against a tree holding 79 kinds, so a lower number means more file types left unclaimed rather than more cryptography left unfound — and the two read identically unless the page says which it is.

The denominator stays conservative anyway: you cannot know a .txt holds no PEM block without opening it, and key files often carry no extension at all.

Concentration, and the partition it is measured over

The block reports where the mass sits, not only how large a total is — because an aggregate over a lopsided population describes its largest members and reads as describing all of them. Measured across five open-source repositories: three file kinds account for 52 % to 89 % of everything not read, and the two largest kinds present are 43 % to 55 % of everything counted.

Three values travel with every share, and the third is the one most tools omit:

  • share — how much the largest members are.
  • cardinality — how many kinds that share is out of. Three of 24 kinds at 69 % is five and a half times a flat split; three of 63 at 68 % is fourteen times one. Without it the same share means different things and cannot be read as high or low at all.
  • partition — what a kind is. A concentration is not a property of an aggregate but of the aggregate crossed with the partition it was measured over: the same tree split by extension, by directory, or by language gives different shares with nothing changing on disk. This block partitions by file extension and says so beside every figure.

The signer's sentence carries the same three inside the bracket holding the count, because a figure lifted out of a document without them is the failure the block exists to prevent (certbot at 56ee049, 0.30.0):

This scan read 823 of 1250 files (65.84%); the remaining 427 (3 of 22, by extension —
files with no extension, .rst and .txt — being 73.09% of them) are listed with a reason each.

The published measurement, with the raw artefacts: quantumreadiness.eu/evidence/scan-coverage

The CBOM it emits

export_cbom produces a CycloneDX 1.6 document, validated against the published schema; from 0.20.0 qrp-mcp cbom PATH --out FILE writes the same document to a file. Three things travel in it that a component list alone cannot say:

  • compositions.aggregate — incomplete where files were not examined, and unknown where the tool cannot account for its own reading. Reading every file is not enough for complete: that word claims every asset present was found, which is the second axis, and only a held control licenses it. The document used to say complete on the strength of the denominator alone; comparing against other scanners showed findings missed inside files that had been read.
  • properties — the whole coverage block. It travels there because the root object is additionalProperties: false and the format has no field for it; the awkwardness is the point rather than something to hide.
  • evidence.occurrences — file, line and matched text for every asset.

A policy or an IKE configuration is a protocol component whose qrp: properties carry what it says — qrp:security_policy and qrp:policy_status (OPC UA, protocol type other, which is CycloneDX's word for a protocol it has no name for), qrp:policy, qrp:allows_versions, qrp:post_quantum and qrp:default_applied (AWS), qrp:product, qrp:fragmentation and qrp:fragmentation_default (IKE) — because the matched text is masked by default and cannot carry them.

Where the scan named the module, an algorithm component carries qrp:providers (a JSON array) and qrp:provider_validation, which says that validation is not claimed and has to be checked against the CMVP list for the deployed version. certificationLevel is never written: it states a validation, and nothing in a file establishes one. From 0.32.0 the same component carries qrp:provider_pins (a JSON object, provider → "version (manifest:line)") where a manifest pins the provider's package, and qrp:provider_configuration where an openssl.cnf in the tree activates the FIPS provider for a component whose provider is OpenSSL 3. A DNS size-budget asset carries qrp:udp_setting, qrp:udp_size and, for BIND's default, qrp:default_applied; a weak EC key is a related-crypto-material component with qrp:curve, qrp:scalar and qrp:bound.

Output is deterministic where it matters. The serial number is derived from the target, the two pins and a digest of what was found, so the same code over the same corpus that finds the same things gets the same serial, and a different result gets a different one. The timestamp and the coverage window record when the run happened, so those fields differ between runs.

What kind of place a finding sits in

Two things about a match are not the match itself, and both now travel with it — in the scan result and in the exported CBOM.

Evidence kind. scan_repo grades every match: call, declaration, import, reference, ban, comment, and default -- a value the provider applies because the file wrote none (an AWS listener with no ssl_policy), counted as in use, never as a comment or a ban. Comment evidence is kept out of the inventory: a sentence that names an algorithm is not a use of it. (An earlier version of this paragraph gave precision figures here with the two tools swapped; the measured figures are under "Measured on a corpus this project did not write" below.) The exported document used to drop that grade, so a sentence about certificates reached an auditor looking exactly like a signature: 78 of 465 occurrences on certbot (17%) and 1174 of 7142 on OpenSSH (16%), including 493 ML-DSA and 429 ML-KEM mentions in OpenSSH, which is a lot of prose to present as post-quantum adoption. An occurrence whose evidence is a comment now says [comment].

⚠️ A banned algorithm is not marked, it is absent: !RC4 in a cipher list never becomes a component. What the line forbids is not inventory; what it enables is.

Test code. A finding says whether it sits in test code, and the scan declares the rule it used:

"test_code": {
  "rule": "a path component named test/tests/testing/spec/specs/fixtures/testdata, or a file named test_*, conftest, *_test, *.test.*, *Test, *Tests, *_spec, *.spec",
  "meaning": "whether the finding sits in test code; named, not excluded",
  "findings_in_test_code": 144,
  "findings_in_other_code": 301
}

Components whose every sighting is test code also carry the standard field — component.scope: "excluded", which the schema has defined since 1.6 as documenting "component usage for test and other non-runtime purposes". ⚠️ v0.18.0 shipped the per-occurrence marker and no scope at all, which was a private spelling of a field the standard already had.

⚠️ And the honest measurement of that field: on certbot, not one of the 31 components is confined to test code — every family that appears in a fixture also runs in production. At component granularity excluded almost never fires on a real repository, which is exactly why the per-occurrence marker is not redundant: scope is a property of the component, [test] is a property of one sighting, and 144 of 445 findings need the second.

Those are certbot's real numbers: almost a third of its findings are in fixtures. Nothing is dropped — a fixture's RSA key is real RSA. Whether it belongs in a particular migration plan is the reader's call, and they can only make it if the document says which is which. Occurrences in test code carry [test], and a comment inside a fixture carries both.

⚠️ There is no external convention for this, which is why it is declared rather than decided. The two public corpora that could settle it — qscan's recall benchmark and the cryben corpus of Näther & Hirsch — are 100% synthetic fixtures with no test/production distinction. The rule here is a path convention, not a fact, so it is printed with the numbers it produced; a reader who disagrees with the rule can see exactly what it caught.

What a TLS line does not establish

A line that configures TLS — ssl_ecdh_curve, SSLOpenSSLConfCmd Curves, ssl_protocols, MinVersion — is what this server asks for, not what a connection got. The group is settled where TLS terminates, together with the client, and that may be a proxy, a load balancer or a CDN that is not in the tree. A CDN that upgrades origins on its own makes a domain show X25519MLKEM768 that nobody in the organisation configured; a classical terminator in front of a post-quantum origin does the reverse.

This scanner reads files, so on the first case it reports the origin as classical: right about the file, silent about the wire. Since 0.27.1 it says so, wherever it read TLS configuration and nowhere else:

"tls_termination": {
  "statement": "These files configure TLS. This scan reads files, not connections. ...",
  "configured_in": [
    {"path": "certbot/src/certbot/_internal/plugins/apache/tls_configs/current-options-ssl-apache.conf", "line": 11, "basis": "configured_group"}
  ]
}

basis is configured_group for a setting that chooses key-exchange groups, configured_protocol for a pinned version and, from 0.32.0, configured_cipher_suite for a setting that chooses cipher suites; from 0.33.0 configured_listener for an HAProxy bind … ssl. null when the tree configures no TLS; SSH configuration is not counted here. On certbot: 42 places (25 protocol pins, 16 cipher-suite settings, 1 group setting; 23 before 0.32.0, 39 before 0.33.0; measured on 0.34.0 and 0.35.0); on OpenSSH: none. The statement stays out of the CBOM, like files_read.

From 0.31.0 the caveat is also beside every TLS setting, not only once per scan. Every evidence item at one of those places — a pinned version under evidence.protocols, the source, CI or IaC item on a group-setting line, and each classical group offered alone under evidence.tls_groups — carries one short field that points at the statement:

"tls_termination": "Configured, not negotiated: a terminator outside the files can negotiate otherwise. See tls_termination.statement."

It is the same in scan --out, in scan_repo at every level, and in prove_closure's inputs, where it is not part of an occurrence's identity, so a result written before 0.31.0 compares with one written after. In the CBOM every TLS protocol component and every (TLS group offered alone) component carries it as qrp:tls_termination; the long statement stays out. An algorithm component (X25519, ML-KEM) is not marked: it gathers occurrences from TLS and non-TLS lines alike, so a line setting only hybrid groups is marked in the scan result and not in the CBOM. On certbot: 54 evidence items at the 23 places; on OpenSSH: none. Nothing else changes: the same files read, the same evidence and findings as 0.30.0. SSH settings and commented-out lines carry nothing.

From 0.32.0 that sentence is true of cipher suites as well. In 0.31.0 "every TLS setting" meant protocol pins and group settings; a cipher-suite line — nginx ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:AES256-SHA, HAProxy ssl-default-bind-ciphers — gave its ECDH and RSA without the pointer and was not in configured_in (we said so in public on 22 Oct 2026). A suite is negotiated where TLS terminates, like a group, so a line that sets cipher suites is now a place: nginx *ssl_ciphers, Apache SSLCipherSuite/SSLProxyCipherSuite, HAProxy's ssl-default-bind/server-ciphers(uites) and ciphers/ciphersuites on a bind or server line, OpenSSL's CipherString/Ciphersuites (and through ssl_conf_command / SSLOpenSSLConfCmd), SSL_CTX_set_cipher_list, set_ciphersuites, Python's set_ciphers, Envoy's cipher_suites, Go's CipherSuites, Java's setEnabledCipherSuites. A suite name on its own is not: OpenSSL's headers define every suite as a C constant, and those configure nothing. On certbot this adds 16 places and 50 evidence items with the pointer (104 in all); OpenSSH is unchanged at none. In the CBOM nothing new is marked, for the reason above: the algorithm components gather TLS and non-TLS lines alike.

HAProxy (from 0.32.0, raised by Kiran Murthy: "validate runtime negotiation points such as load balancers"). Until then an haproxy.cfg with ssl-default-bind-curves X25519MLKEM768:X25519 and ssl-default-bind-options ssl-min-ver TLSv1.2 gave its algorithms and nothing else — no TLS place, no pin, no classical group offered alone, all three of which the same settings in nginx got. Now ssl-min-ver/ssl-max-ver and ssl-default-bind/server-options are read as protocol pins, and ssl-default-bind/server-curves and curves on a bind or server line as group settings; the names are HAProxy's own (Configuration Manual 3.5, doc/configuration.txt, read 22 Oct 2026). ecdhe <curve> is not read: ECDHE is in nearly every cipher suite. Of the other terminators, Envoy (tls_minimum_protocol_version: TLSv1_2, ecdh_curves, cipher_suites) was already read; Traefik (minVersion: VersionTLS12, a curvePreferences list over several lines) and Caddy (a Caddyfile has no extension and was not read; from 0.35.0 it is, and x25519 in it is found as X25519, but protocols tls1.2 and the curves line are still not recognised as a TLS place) are not, tested on 22 Oct 2026.

From 0.33.0, after a local probe of 22 Oct 2026 that put a network scan of three TLS endpoints beside a scan of the repository that configures them, five things that probe showed the code scan missing:

  • An HAProxy listener that terminates TLS is a place. bind :8443 ssl crt edge.pem is a protocol asset with basis: configured_listener and is in configured_in, and it names what neither its line nor the file's ssl-default-bind-* sets — library_defaults: ["protocol versions", "TLS 1.2 cipher list", "TLS 1.3 cipher suites", "groups"], as many of them as are absent. There HAProxy's and the TLS library's defaults apply, and which they are depends on the versions that run, which the file does not say; the one default stated is HAProxy 3.2's own manual's (ssl-min-ver: "The default value is TLSv1.2"), named as that. The probe's edge listener said nothing in 0.32.0 and took TLS 1.2 with 13 suites on the wire.
  • RSA key exchange is named. OpenSSL calls TLS_RSA_WITH_AES_256_CBC_SHA AES256-SHA: no word in the name says RSA, so SSLCipherSuite HIGH:!aNULL:AES256-SHA:DES-CBC3-SHA gave 3DES and nothing else. On a cipher-suite setting the 30 TLS_RSA_WITH_* names of openssl-ciphers(1) (OpenSSL 3.5), the kRSA keyword and the IANA spelling are now evidence.static_rsa_kex — RSA key exchange: no forward secrecy, and a recorded session opens to whoever later recovers the server's RSA key — with RSA on the line and a CBOM component RSA key exchange (static RSA cipher suites). ECDHE-RSA-… stays RSA by authentication only; a suite struck out with ! or -, a comment, a C constant and a lower-case strongSwan proposal are not counted. On certbot: 2 lines (an Apache options file in the integration-test assets and a test vhost).
  • SSLProtocol all enables what it does not strike out. mod_ssl's manual (2.4): all is +SSLv3 +TLSv1 +TLSv1.1 +TLSv1.2 +TLSv1.3 with OpenSSL 1.0.1 and later (TLSv1.3 with 1.1.1, SSLv3 not where OpenSSL was built with no-ssl3). So SSLProtocol all -SSLv3, which 0.32.0 reported only as "SSLv3 is forbidden here", now also gives TLSv1.0 to TLSv1.3, each with included_by: "all", the two old ones deprecated. Whether the TLS library still allows them at run time is not in the line. On certbot: 60 protocol assets more (TLS places 39 → 42).
  • A backend whose certificate is not verified. HAProxy verify none on a server or default-server line, its global ssl-server-verify none, and nginx proxy_ssl_verify off are protocol assets with peer_verification: "off" (basis: configured_verification): the hop is encrypted and not authenticated, and it is the one thing a scan from outside cannot see. They are not termination places. verify on a bind line is the client-certificate check and is left alone. ⚠️ nginx's default is also off (ngx_http_proxy_module: "Default: proxy_ssl_verify off"); a file without the line is not reported, and that is a boundary.
  • A hybrid group beside the image that has to negotiate it. evidence.library_support: for each image: line (compose, Kubernetes) naming a TLS server image — nginx, haproxy, httpd, traefik, caddy, envoy, openresty — in a tree that configures a hybrid group (the IANA TLS Supported Groups names: X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024, the ML-KEM-512 drafts, X25519Kyber768Draft00 …), whether that image can negotiate it. support: "not_supported" only where every step was read at a source: OpenSSL added the three RFC 10024 hybrids in 3.5.0 (CHANGES.md), and nginx:1.24.0 is docker-nginx's Debian image, FROM debian:bullseye-slim, whose openssl is 1.1.1w. That is the one image in the table; every other one, an -alpine tag included, is "not_established", and says so. A compose service is joined to the configuration it mounts from the tree (serves, serves_group_places); without a mount the pin and the groups are named side by side, not joined. In the probe the repository set X25519MLKEM768 for a service that ops ran on nginx:1.24.0 with a classical hotfix mounted instead; nginx refused the repository's configuration. A Dockerfile is read from 0.35.0 for the algorithms it names, but its FROM nginx:1.24.0 is not read as a pin, and a package pin is not read for this. The places and the mounts are not part of the finding's identity in closure; in the CBOM it is a container component. On OpenSSH and certbot: none.

The same probe said that two comments were counted as uses — # … classical X25519 as fallback. above HAProxy's curves line, and # … hybrid ML-KEM group preferred. in an nginx file whose server did not offer ML-KEM on the wire. Reproduced against 0.32.0, that is not so here: both are evidence_kind: comment, outside detected_algorithms and the TLS places, and [comment] in the CBOM (a test now pins it).

The statement says the same of authentication: a Merkle Tree Certificate is issued by the CA, verified by the browser and often served from an edge, so an origin whose files name an ECDSA key can be served with one — and its own key is still ECDSA, and reported as such. An AWS listener policy is a TLS terminator in the tree, and counts as a place here.

From 0.34.0, after a systematic probe of 22 Oct 2026 that set the summary beside what the scan had actually read, and four points raised in public the same day, the output stops being confident where the evidence beside it was not:

  • pqc_ready is withheld past what was not read. A value that says nothing classical is present — pqc_ready, pqc_pre_standard, mechanism_protected, no_quantum_vulnerable_detected — becomes not_established when files in the tree were not read (or a directory not entered, a linked directory not followed), or when a private key's algorithm was not determined (PKCS#8 embedded in source, an encrypted key file). summary.readiness_not_established says what it would have read and why not, e.g. "1 of 2 files were not read (50.0% read); see coverage.not_examined". A value that warns is never withheld. In practice nearly every real repository has a file the scanner does not read (a README.md, a LICENSE), so on a real tree these four values give way to not_established, with the would-be value beside it. The case: ML-KEM in a module and a Caddyfile with curves x25519, which was not read (it is from 0.35.0, and the same tree now reads hybrid_partial); the summary said pqc_ready about the one file it had not opened (raised by Chandra Devadass). Files removed by the directory exclusions do not withhold it; they are named in the verdict instead (below).

  • The verdict names what the directory exclusions removed. The directories left out before anything is counted are .git, node_modules, venv, .venv, __pycache__, vendor, dist, build, .tox, target, .pytest_cache, .mypy_cache and .ruff_cache (coverage.instrument.excluded_dirs). A file under vendor/ said "read every one of the 3 files" of a 4-file tree; now the sentence ends "Left out before counting, by directory name: 1 file in vendor/." .git is not named there (raised by Muhammad Atif).

  • A post-quantum family is not counted as ready where the evidence says it does not hold. Where every use is a TLS place that a pinned image cannot negotiate (evidence.library_support, not_negotiable), the finding names the image in not_negotiable_by (also qrp:not_negotiable_by on the CBOM component) and the summary leaves it out of pqc_ready_count and the readiness. Where every use is test code, the finding carries in_test_code: true and the same applies; the CBOM already marked it scope: excluded. The classification is unchanged — ML-KEM is standardised — and test-only classical code still counts as vulnerable: the summary does not reassure from what it set aside, and never warns less.

  • What an RFC 8784 PPK protects. One PPK line made a whole tree mechanism_protected with RSA signing in another file. The PPK finding now lists protects: the families used in the files that configure it. mechanism_protected needs every quantum-vulnerable family among them; otherwise the readiness is what it would be without the mechanism. Per family, not per occurrence: EC in the IKE file and EC elsewhere are one family. A PPK whose file says ppk_required = no (or switches it off) protects nothing and its reason says it is optional. ⚠️ strongSwan's default for ppk_required is also no; a ppk_id with no ppk_required line is not yet read as optional.

  • A recoverable private key raises its finding. Where evidence.weak_keys finds k·G, the finding of that family is weak_key: true, severity critical, with the place in its reason, so weak_count and highest_severity say it too (raised by Sagar A.).

  • A denial block governs names, not calls. if (err.code === 'ERR_UNSUPPORTED') { or except UnsupportedAlgorithm: opened a "denial block", and the classical fallback generated inside it read as a ban. A call is now a use wherever it sits; names listed under forbiddenAlgorithms: stay bans. A declaration in such a block (curve = 'P-256') is still read as a ban.

  • A group removed with OpenSSL's - is a ban on the algorithm path too. Groups = DEFAULT:-X25519MLKEM768 switches the hybrid off; ML-KEM was detected from it. The algorithm rule now asks the same group-setting test as the TLS group rule.

  • LMS through its API. wolfSSL wc_LmsKey_* (its lms.h), hash-sigs' hss_validate_signature and the other hss_* entry points for generating, loading and signing, and Bouncy Castle's LMSSigner/HSSSigner are HSS/LMS (raised by Dhiraj S.). XMSS through wc_XmssKey_*, xmss_sign_open and XMSSSigner was already matched and is now pinned.

  • prove_closure does not call a skipped file removed. A key file the second run skipped by type was "removed — the file is not in the second tree" while it was there; every path in coverage.not_examined is now unread, so such an occurrence is unverifiable.

  • UTF-16 without a byte-order mark is read as UTF-16. A .ps1 saved by a Windows tool as UTF-16-LE with no BOM was decoded as UTF-8, gave nothing, and counted as read. One NUL in every byte pair, on the same side, over the first 4 KiB now selects UTF-16; text with more than about a tenth non-ASCII characters is not recognised this way.

  • Deprecated TLS versions reach the summary. summary.deprecated_protocols lists the deprecated versions enabled in the tree (TLSv1.0, TLSv1.1, SSLv3 …); a version struck out is not listed. weak_count still counts keys and algorithms only.

  • The coverage identity has four terms. A linked file is in files_present and in symlinks_not_followed, not in the other three (below).

Not changed by any of this: what is detected, apart from the LMS names, the two ban rules and UTF-16 above, and the coverage figures. On OpenSSH the files read, the evidence, the findings, the summary's readiness and the verdict are the same as in 0.33.0. On certbot the same, but for one evidence line: make_key(elliptic_curve="nothere", key_type='ecdsa') under with pytest.raises(…, match='Unsupported elliptic curve…'): in a test is now a call, not a ban (ECDSA was found elsewhere anyway); its summary gains deprecated_protocols. No file in OpenSSH, certbot or wolfSSL is UTF-16 without a BOM.

From 0.35.0, after three probes of 22 and 23 Oct 2026 — a swarm that wrote each library's own spellings as tests, a corpus probe with the expected inventory written before the scan, and points raised on LinkedIn — what is read and named grows, and the summary says more:

  • Library spellings, each checked against the library's source. OpenSSL's DSA_sign, DH_compute_key, NID_ffdhe*, EVP_PKEY_CTX_set_rsa_*, RSA_size, MD5()/SHA1(), DES_ecb3_encrypt and the rsa.h/dsa.h/dh.h/md5.h headers; Ed25519 glued into an identifier (ED25519_sign, EVP_PKEY_ED25519, Ed25519PrivateKey, ed25519_dalek, x509.PureEd25519); libsodium and PyNaCl (crypto_sign_detached is Ed25519, crypto_box_*, crypto_kx_*, crypto_scalarmult* and nacl.public are X25519 — not the crypto_sign_keypair/crypto_sign_open every post-quantum reference implementation also exports); JCA names by a table (KeyFactory/KeyAgreement, DH, XDH — X25519 unless initialised otherwise — DES, DESede, ARCFOUR, SHA, BCPQC Falcon) and the JCA EC types (ECGenParameterSpec, ECPublicKey …); Node's 'rsa-pss'/'dsa'/'dh' key types, crypto.publicEncrypt and friends, 'des-cbc', node-forge pki.rsa; .NET DSA.Create, DES.Create, SHA1/MD5.HashData, HashAlgorithmName.SHA1, CngAlgorithm.Rsa; pycryptodome (ECC, pkcs1_15, pss, PKCS1_OAEP, DES, DES3, Hash.MD5/SHA1), python-rsa newkeys, paramiko DSSKey; wolfCrypt's whole classical API (wc_*Rsa*, wc_ecc_*, wc_Dh*, wc_Md5*, wc_Sha*, wc_Des3_*, wc_falcon_*) and headers; Mbed TLS dhm, ecp, md5, sha1, des, des3 and the PSA RSA/DH/ECC key types; Bouncy Castle's RSAEngine, DESEngine, RC4Engine, MD5Digest, SHA1Digest, FalconSigner; ring/aws-lc-rs RsaKeyPair and RSA_PKCS1_*, rustls SignatureScheme::RSA_PSS_*, RustCrypto Md5/Sha1, p256::, @noble/curves; strongSwan's KEY_RSA, SIGN_RSA_*, ENCRYPT_RSA_* and MODP_* (OpenSSH's KEY_RSA too); liboqs's HQC by level (hqc_1) and UOV as uov_ov_*/OV_*; Falcon's padded variant; X-Wing (ML-KEM-768 + X25519, both halves) and CIRCL's eddilithium2/3 (with their Ed25519/Ed448 halves); the classical half of oqs-provider's hybrids (p384_mlkem768 is ECDH + ML-KEM, p256_mldsa44 ECDSA + ML-DSA, rsa3072_* RSA). Three deliberate differences from the probe's expectation: pycryptodome's Crypto.Protocol.DH takes only ECC keys and is ECDH, not DH; Crypto.Signature.DSS signs with a DSA or an ECC key, so its import names neither; Go's tls.CurveP256 is ECDH, as the TLS group table already files it.
  • Four lookalikes are not the algorithm. LMS_SHARED_SECRET (not HSS/LMS), personal_signature (not the personal_sign call), Node's createSign('SHA256') (the key decides; createSign('RSA-SHA256') is still RSA) and ml_dsa.sign (not DSA).
  • Files that were "no extension" or "type not claimed". Dockerfile, Dockerfile-*, *.Dockerfile, Containerfile, Kconfig*, a Caddyfile, extensionless files under mods-available/mods-enabled/sites-available/sites-enabled (FreeRADIUS's EAP-TLS module, Debian's nginx sites), .jsx/.tsx, and the Java keystores .jks/.keystore, whose certificates are clear DER. A lockfile is still read for its packages and no longer matched as source: ecdsa-sig-formatter in a package-lock.json was "ECDSA usage".
  • A TLS group offered alone is a finding of its family. Groups = x448:mlkem768 put X448 in evidence.tls_groups and nowhere else, and the summary said pqc_ready; ffdhe2048 likewise gives DH.
  • What a PKCS#12 file says in clear. Its bag encryption (pbeWithSHAAnd40BitRC2-CBC → RC2, …3-KeyTripleDES-CBC → 3DES, the RC4 forms) and its SHA-1 MAC are named from the unencrypted wrapper; the file is still claimed_but_not_decoded, because the bags were not read. RC2 is a new weak family.
  • COSE algorithm identifiers on a WebAuthn or COSE line ({"type": "public-key", "alg": -7} is ES256), from IANA's registry. A bare "alg": -7 stays a number.
  • RFC 8784, two narrowings. strongSwan's ppk_required defaults to no (its swanctl.opt), so a ppk_id without ppk_required = yes is an optional PPK and protects nothing; and a family is protected only when every use of it sits under a required PPK — EC in a second tunnel without one was counted protected by the first.
  • Two shapes of the denial block. A block a condition opens (} catch (UnsupportedAlgorithmError) {) is the code that runs then, not a list of banned names, so a declaration inside it is a use; a pytest.raises/assertRaises block whose line says "unsupported" is a test that the call inside is rejected, so the call is a ban.
  • Who owns a place. .github/CODEOWNERS, CODEOWNERS or docs/CODEOWNERS (GitHub's order; then .gitlab/CODEOWNERS) is read, never matched for algorithms, and every evidence item gets owners and owners_source (.github/CODEOWNERS:3) from the last matching line — per place, not per component, because one algorithm sits in many teams' code. In the CBOM it is an [owners: … per FILE:N] tag on the occurrence: the schema has no field for it. It is the file's assumption, not a fact about who runs the code; codeowners says which file was used.
  • Key establishment and signatures apart. summary.by_role gives each its families and its own readiness: nginx offering X25519MLKEM768 with an ECDSA certificate is hybrid_partial for key establishment and classical_only for signatures, where the whole read only hybrid_partial. RSA and EC go where their lines' roles put them and are listed under role_not_determined where a line's role could not be read; a reassuring part is withheld with the whole.
  • de-bsi follows the 2026 documents. TR-02102-1 2026-01 recommends FrodoKEM-976/1344 and Classic McEliece beside ML-KEM, each in hybrid; the BMDS/BMI/BSI Leitlinie of 11 Sep 2026 asks for the initial cryptographic inventory by the end of 2026 (§4.3) and high-risk use cases by 2030. A McEliece or FrodoKEM finding under de-bsi keeps NIST's candidate and says, in profile_standing, that the BSI recommends it.
  • A DNSSEC algorithm number with no family is named. A DNSKEY 257 3 253 record (PRIVATEDNS) was dropped without a word; it is now a protocol asset with dnssec_algorithm and IANA's mnemonic, no family is invented, and it withholds pqc_ready like an unread file.
  • FreeRADIUS's tls_min_version = "1.0" is TLS 1.0, deprecated.
  • SHA-224, SHA3-224 and AES-ECB are seen, with what NIST SP 800-131A Rev. 3 proposes — quoted from its initial public draft of October 2024 and said as a draft (no final revision on CSRC on 23 Oct 2026). Their classification is unchanged (hash, symmetric).

On OpenSSH the files read, the coverage (84.77%), the findings, the readiness and the verdict are the same as in 0.34.0; the evidence gains 865 lines (761 Ed25519 identifiers such as KEY_ED25519 and ed25519_pk, 85 KEY_RSA, 11 DH headers and DH_compute_key, 8 more RSA) and loses none. On certbot 9 more files are read (four Dockerfiles, four nginx sites-* files, CODEOWNERS): coverage 65.84% → 66.56%; the findings, the readiness and the 42 TLS places are the same; one evidence line moves from a call to a ban (make_key(… key_type='ecdsa') under pytest.raises(… 'Unsupported elliptic curve …')).

A classical group offered on its own

From 0.28.0 a classical group named as a group of its own in a group setting — X25519 in ssl_ecdh_curve X25519MLKEM768:X25519, prime256v1 in SSLOpenSSLConfCmd Curves, tls.CurveP256 in Go's CurvePreferences, ffdhe2048 in jdk.tls.namedGroups — is a finding of its own, under evidence.tls_groups, with offered: "alone" and basis: "configured_group". A hybrid's name (X25519MLKEM768) is one token and is never counted here; a group removed with OpenSSL's - prefix is not an offer; a name the table does not hold (auto) yields nothing.

Before, removing the classical fallback was invisible: the X25519 inside the hybrid is the same family as X25519 alone, so the scan before and after was identical and closure called the one-line change "3 closed, 3 new". The new finding carries no excerpt — its identity is file, setting and group — so removing the fallback shows as closed, and editing the line around a fallback that stays leaves it still open. per_family_before_after counts it apart, as X25519 offered alone. In the CBOM it is a component, X25519 (TLS group offered alone). The three source findings on the edited line still close and reopen, because their identity includes the line's text; that is how every edited line has always been compared. On certbot: 3 (X25519, prime256v1, secp384r1 in the Apache options file); on OpenSSH: none.

⚠️ What is not claimed: that a connection ever uses the fallback. That is settled where TLS terminates, as above; the finding is about the file.

A classical SSH key exchange offered on its own

The same defect existed in OpenSSH until 0.31.0: KexAlgorithms mlkem768x25519-sha256,curve25519-sha256 and KexAlgorithms mlkem768x25519-sha256 scanned the same, because the X25519 inside the hybrid's name is the family of curve25519-sha256 too, and removing the classical fallback from sshd_config closed nothing. Now a classical algorithm in a KexAlgorithms list (sshd_config, ssh_config, ssh -o KexAlgorithms=…) is a finding of its own, under evidence.ssh_kex, with offered: "alone", setting: "KexAlgorithms" and basis: "configured_kex". The names are OpenSSH's own — every algorithm sshd_config(5) lists that kex-names.c marks as not post-quantum (OpenSSH at 7fe3b24): curve25519-sha256 (and @libssh.org), ecdh-sha2-nistp256/384/521 and the diffie-hellman-group* names. The hybrids (mlkem768x25519-sha256, mlkem768nistp256-sha256, sntrup761x25519-sha512) are never counted. OpenSSH's prefix applies to the whole list: a list beginning with - removes what it names, so nothing in it is an offer; + (appended to the default) and ^ (placed at its head) still offer it, and the finding says the default set is not named on that line.

It is kept apart from tls_groups because an sshd_config is not a TLS configuration: it adds no place to tls_termination. In closure removing the fallback is closed (curve25519-sha256 offered alone), and in the CBOM it is a component, curve25519-sha256 (SSH key exchange offered alone). On OpenSSH: 3, all in regress/ test scripts; on certbot: none. Nothing else in either scan changes: the same files read (740 of 873, 823 of 1,250) and the same evidence and findings as 0.30.0.

⚠️ What is not claimed: which algorithm a connection uses. The client chooses from what the server offers, and an sshd built without a hybrid, or a jump host in front, is not in the files.

⚠️ What is not claimed: that the tree's TLS settings are all listed, or which terminator is in front. The scanner cannot see a connection; the field says that, not what the connection is. Raised in public on 1 October 2026, where a measurement at the front door met the same gap from the other side.

Whether a fix closed it

A finding missing from the second scan has not necessarily been fixed. It may have moved down the file, moved to another file, landed in a directory the second run could not open, or stopped matching because the scanner changed. Each of those looks exactly like a fix when the only question is "is it still in the list".

prove_closure(before, after) takes two saved results (qrp-mcp scan PATH --out FILE) and answers in two steps:

  1. Can these two runs be compared for closure? Same instrument commit, version, rules, file types and exclusions; both trees pinned to a commit and clean; both results quoting lines at the same level. Run from an installed release (uvx qrp-mcp, pip install) there is no commit to name; two runs of the same installed release are accepted on its version, and the result says so in basis — weaker than a commit, since a locally modified install would carry the same version string. The trees may differ — that is what is measured. If the runs cannot be compared, nothing is reported as closed, the reasons are listed, and the repair is named: scan the first commit again with the instrument that did the second.
  2. Only then, occurrence by occurrence, without line numbers: closed, still open (and how many only moved lines), relocated to another path (a rename is not a fix), moved into or out of test code (neither is a fix), new, unverifiable — in a file the second run did not read — and removed: the whole file is gone from the second tree. A deleted file does remove its code, but it is also the cheapest way to make a finding disappear, so it is named apart and not counted as closed. A file the second run could not open is never counted as fixed. An occurrence's provider is shown but is not part of its identity: it can come from the file's import line, and editing that line does not close the calls below it.

From 0.20.0 every scan lists files_read by name, beside the counts. That is what tells a fixed line from a deleted file; it stays out of the CBOM. Two results without it (0.19.0) are reported as unestablished, not compared on a weaker rule.

Checked on a copy of certbot (521 occurrences): removing one RSA call closed exactly that one; inserting blank lines closed none and reported two moved; renaming a file closed none and reported two relocated; a masked scan against a full one was refused. Two scans of OpenSSH with the same instrument: 7,143 still open, 0 closed. A second run with an emitter that had uncommitted changes was refused as instrument_dirty. With 0.20.0 installed in a clean environment, deleting one RSA line in client.py and the whole of challenges.py gave 1 closed, 1 removed, each on the right file and line.

⚠️ What is not claimed: that the fix is correct, only that the occurrence is gone from every file the second run read. Other scanners close findings between scans too — Semgrep, SonarQube, GitHub code scanning, and Keyfactor AgileSec ("Deleted findings are automatically resolved", 3.4 release notes). Semgrep marks a finding "Removed" rather than "Fixed" when its rule changed or its path went away, and GitHub records the tool version to track changes it caused; both do this as a status after the fact. What was not found in the sources read is a refusal, before calling anything fixed, when the reader changed between the two scans. SonarQube documents that a file dropped from scope is counted as fixed. They keep a history across many scans; this compares two documents. A statement about the sources read on 2026-09-27, not a survey of every tool.

What to replace it with

Each finding that is quantum-vulnerable or deprecated carries a replacement: what to use, for which role, and the standard that says so, with its address. It is a suggestion; nothing is applied, nothing leaves the machine, no model is asked.

Found Suggested Source
ECDH, X25519, X448, DH ML-KEM FIPS 203 (final, 13 Aug 2024)
ECDSA, EdDSA, Ed25519, Ed448, DSA ML-DSA or SLH-DSA; LMS/XMSS for firmware signing FIPS 204, FIPS 205, SP 800-208
RSA, EC by the role read from each line (below); both where a line does not show it as above
RSA below 2048 bits two steps: ≥ 2048 now, post-quantum next SP 800-131A Rev. 2
BLS, Schnorr no approved drop-in replacement — the aggregation property has none; a redesign FIPS 204/205 as the nearest
MD5, SHA-1 SHA-256 or SHA3-256 RFC 6151, SP 800-131A Rev. 2
RC4, DES, 3DES AES (AES-GCM for RC4's place) RFC 7465, FIPS 46-3 (withdrawn), SP 800-131A Rev. 2
a post-quantum scheme that is not a NIST standard a standardised one FIPS 203/204/205

Transition dates come from NIST IR 8547, which is still an initial public draft (12 Nov 2024): quantum-vulnerable signatures and key establishment deprecated after 2030 at 112-bit strength, disallowed after 2035. Hybrids are named as NIST names them — accommodated, and temporary. The CBOM is not changed: CycloneDX has no standard field for a recommendation, and this project does not add a private one.

Whose rules: national profiles

The table above is NIST's. Other authorities disagree with it and with each other, so from 0.22.0 --profile (qrp-mcp scan) and profile (the scan_repo tool) choose whose words fill the options. The CBOM carries no replacement, so it has no profile. Detection is byte-for-byte the same under every profile; only replacement changes, and the result names the profile in replacement_profile.

profile authority · document key establishment signatures hybrid
nist (default) NIST · FIPS 203/204/205 ML-KEM ML-DSA, SLH-DSA; LMS/XMSS for firmware accommodated, temporary
us-cnsa2 NSA · CNSA 2.0 FAQ v2.1 (Dec 2024), CNSSP 15 ML-KEM-1024 only ML-DSA-87 only; SLH-DSA not approved; LMS/XMSS single-tree not required
uk-ncsc NCSC · Next steps v2.0 (Aug 2024) ML-KEM-768 recommended ML-DSA-65 recommended allowed, at a cost
au-ism ASD · ISM (Sep 2026) ML-KEM-1024; 768 not beyond 2030 ML-DSA-87; 65 not beyond 2030; no hash-based not recommended, not prohibited
ca-cccs CCCS · ITSP.40.111 v5 (May 2026) ML-KEM-512/768/1024 ML-DSA-44/65/87, SLH-DSA; LMS/HSS/XMSS/XMSS^MT not addressed
de-bsi BSI · TR-02102-1 (2026-01) ML-KEM-768/1024, hybrid ML-DSA-65/87 hedged, hybrid; hash-based alone lattice schemes recommended only in hybrid form
fr-anssi ANSSI · PG-083 v3.00 (Mar 2026) ML-KEM-768 (512 conformant), hybrid ML-DSA hybrid only; SLH-DSA alone required for ML-KEM, ML-DSA
nl-ncsc AIVD/CWI/TNO · PQC Migration Handbook, 2nd ed. (Dec 2024) ML-KEM-1024/768 ML-DSA-87/65 recommended
eu-eccg ECCG · Agreed Cryptographic Mechanisms v2.0 (Apr 2025) ML-KEM-1024/768, combined ML-DSA-87/65 combined; hash-based alone lattice not standalone
bg — · no Bulgarian guidance found NIST's NIST's EU roadmap: end-2026 / 2030 / 2035

⚠️ What a profile is not. Each is one document read on 2026-09-27, and each says whom that document addresses — CNSA 2.0 is written for US National Security Systems, the ECCG list for EU product certification, the NCSC paper for OFFICIAL-tier and enterprise data. Choosing one does not make it law for the reader, and the tool makes no legal finding. For the weak algorithms (MD5, SHA-1, RC4, DES, 3DES) and for the first step of an RSA key under 2048 bits, an authority's own minimum replaces NIST's row where it has been checked against the text:

profile weak hash → weak cipher → RSA under 2048, now →
us-cnsa2 SHA-384 or SHA-512 AES-256 (CNSA 2.0 admits no RSA; NIST's step)
de-bsi SHA-256/384/512, SHA-512/256, SHA3 (Table 4.1) NIST's at least 3000 bits
fr-anssi ≥ 256-bit output; ≥ 384 for post-quantum security AES-128; AES-192/256 for post-quantum security 2048 until 2030, 3072 from 2031 (3072 recommended now)
au-ism SHA-384 or SHA-512 (224/256 not beyond 2030) AES-128/192/256, preferably 256 at least 2048, preferably 3072; RSA not beyond 2030
ca-cccs NIST's NIST's at least 2048, at least 3072 by the end of 2030

Where a cell says NIST's, the result says so in follows or first_step_follows. A role an authority does not fill is left out rather than filled with NIST's answer: the ISM names no hash-based signature, so au-ism offers none. Documents change — the ISM quarterly, TR-02102-1 yearly — and each profile carries its version and address so a stale one can be recognised.

The role of an RSA or EC line

From 0.21.0 every RSA and EC occurrence carries a role, read from its own line: signature (RS256, rsa-sha2-256, SignPSS, sha256WithRSAEncryption, ECDHE-RSA-… in a cipher list), key_establishment (OAEP, EncryptPKCS1v15, TLS_RSA_WITH_…, a kex table) or undetermined. A key generated or declared on a line is used somewhere else, and this reads one line, so such a line stays undetermined; so does a line with signals for both, and a cipher list longer than the 200-character excerpt. The finding's replacement counts the roles of its lines of code (comments carry a role but are not counted) in roles_seen, and drops a path only when no line showed that role and no line was undetermined. Every path says how many lines it is for.

⚠️ Against this: on every corpus measured, both paths stayed. Undetermined lines of code: certbot 208 of 229, OpenSSH 172 of 456, Vault 361 of 656, OpenSSL 3,354 of 3,893. The count is the gain, not a shorter list.

Role-assignment precision on a sample (not detection; judged from the source ±5 lines, not from the excerpt the rule saw; rules frozen at 3287c1c before these corpora were opened; seed 20260927):

corpus signature correct key_establishment correct undetermined with the role visible nearby
Vault 41e571b 30 / 30 17 / 20 5 / 20
OpenSSL 223e04f 25 / 30 (5 should have been undetermined) 14 / 20 4 / 20
Bitcoin Core 33a363e none to sample none to sample 0 / 8

The nine wrong key_establishment lines had two causes, both in TLS names: TLS_ECDHE_RSA_WITH_… written with underscores was read as ECDH, and in ECDH_RSA suites RSA only signs the certificate of a fixed ECDH key. Fixed in 0.23.0, together with lines that name PSS beside the generic RSA key type (a table of both uses, now undetermined); on Vault and OpenSSL 13 of the 14 are now right. Measured again on two corpora these rules had not seen (rules frozen at 8ab8e9c, seed 20260928, same method):

corpus TLS-name lines key_establishment signature undetermined with the role visible nearby
curl b3640b0 17 / 17 20 / 20 30 / 30 13 / 20
Mbed TLS c0748be 20 / 20 8 / 8 30 / 30 17 / 20

No wrong role in 125 assigned. ⚠️ The samples are concentrated in tables of cipher suites (47 of 70 curl lines from one test file), which is the easiest case for a rule that reads one line. And the misses are systematic: TLS_RSA_PSK_WITH_… (RSA transports the key) and TLS groups for EC (groups=secp256r1) are left undetermined although the role is plain.

0.24.0 reads both: RSA-PSK as key establishment, and a TLS group as key establishment in its TLS spellings only (groups=, set1_groups_list, +GROUP-, selected_group, IANA_TLS_GROUP_) — EC_GROUP_new and ecp_group are the curve's mathematical group and say nothing about the role. Measured on two more corpora these rules had not seen (frozen at b2eb73b, seed 20260929):

corpus psk/group lines assigned key_establishment signature undetermined with the role visible
wolfSSL 3c5eead 1 / 1 15 / 20 (5 should have been undetermined) 30 / 30 4 / 20
s2n-tls e691294 3 / 3 20 / 20 30 / 30 5 / 20

Still no wrong role. ⚠️ But the group fix reached little: 4 of 40 psk/group lines. s2n-tls names its groups Group::secp384r1, "group.supported.secp256r1", kem_group; wolfSSL ctx->group[0] — the rule was written from Mbed TLS's spellings, and 15 of 17 s2n-tls group lines stay undetermined. And in C RSA_public_decrypt verifies a signature; a switch that handles it together with RSA_PUBLIC_ENCRYPT was called key establishment (the five above).

0.25.0 reads the raw RSA signature operations of C (RSA_public_decrypt, RSA_private_encrypt) as signature (a role is only put on a finding, and until 0.27.0 those two lines were found only when something else on the line named RSA), and the other group spellings (Group::, NamedGroup, kx_group, kem_group, group.supported, supported_groups, negotiated_curve). Measured on BoringSSL and rustls, which these rules had not seen (frozen at b8bd5ce, seed 20260930):

corpus crypt/group lines assigned key_establishment signature undetermined with the role visible
rustls 99f2358 18 / 18 20 / 20 30 / 30 2 / 20
BoringSSL 5112448 1 / 3 14 / 20 30 / 30 2 / 20

🔴 Seven wrong roles in BoringSSL, one cause, and it was in 0.24.0 as released: the rule for group( — written for Mbed TLS's psk_ephemeral group(secp256r1) — also took BoringSSL's C++ accessor group(), which returns the curve's mathematical group, as a TLS group (EC_POINT_mul(group(), …) → key establishment). 0.25.0 accepts group( only with a curve or group name inside. That narrows the rule and cannot add a role: on BoringSSL exactly 95 lines change, all from key establishment to undetermined, all group(). The seven are among them. The fix itself was not measured on a corpus it had not seen.

Measured against the other scanners

In September 2026 three free tools that do the same job — CryptoScan, CBOMkit-hyperion (sonar-cryptography) and CBOMkit-theia — were run over the same repositories and the findings compared line by line. What they found and this tool did not became the 0.8.0, 0.8.1 and 0.9.0 releases. Of what this tool does that they did not, one claim needed narrowing when a wider survey was done, and it is corrected here:

  • Coverage inside the document. Several tools do report what they skipped: QuantaKrypto's qscan counts scanned and unread files, IBM Quantum Safe Explorer logs each excluded file with a reason, SandboxAQ shows missed locations. What we have not found elsewhere is the coverage travelling inside the CycloneDX CBOM — a reason per group of unread files, the paths that could not be read, the directories that could not be entered, and accounts_for_every_file. qscan computes the counts and drops them on export, so it is one flag away from the same thing.
  • This scanner separates reading from finding in the document itself, and refuses to say complete without a control that licenses the stronger claim. Reading is self-measurable; finding is not.
  • sntrup761, the default hybrid in OpenSSH from 9.0 to 9.9 — OpenSSH 10 defaults to mlkem768x25519-sha256 — has no rule in CryptoScan; this tool reports 145 lines of it in the OpenSSH tree.
  • Private keys are recognised by content, not by file name: the 45 key files CBOMkit-theia found in OpenSSH and this tool did not are read from 0.9.0, and a PEM header with the body elided — documentation — is not one.
  • An excluded cipher (!MD5) is counted as a use by CryptoScan; here it is an exclusion.
  • On certbot, this scanner finds algorithms in 26 files against hyperion's 7, and hyperion's one extra finding is wrong (RSA-96 where certbot defaults to 2048).

Measured on a corpus this project did not write

The nearest tool of the same kind is QuantaKrypto's qscan — lexical like this one by its own changelog, and the only other tool in this class that publishes a detection figure. In September 2026 both were run against Cryben (Näther & Hirsch, arXiv 2608.04857): an independent corpus with its own reference CBOM and its own scorer, written by neither of us. The scripts and the raw output are reproducible; the method matters more than the number.

qscan 0.12.0 this tool 0.8.1 this tool 0.11.0
Cryben, in the scope this tool declares 30/37 22/37 36/37
qscan's own corpus, qscan's own metric 0.847 0.511 0.909
the same, no wildcard credit for either tool 0.790 — 0.818 (see below)
qscan's corpus without its structural labels 0.802 0.714 0.944
Cryben, full 197 findings, precision 0.93 — 0.667
Cryben, full 197 findings, F1 0.34 — 0.346
a negative corpus, 200 files with no such cryptography — — 200 clean
Vault, wall clock 7.9 s 173 s 219 s

⚠️ Every figure in that table was measured on 0.11.0 and has not been re-measured since. 0.12.0 changes what counts as a use: an external retest found that a denial word anywhere on a line -- including inside a comment, including the word weak in a variable name -- turned a real key into a ban and dropped it from the inventory. Fixing that necessarily moves both recall and precision, in directions this table cannot state until the corpora are run again. The numbers below are the previous release's, labelled as such rather than quietly carried forward.

Three denominators appear in that table and they are not the same question. 37 is the number of Cryben cases inside this tool's declared scope; 176 is the label count in qscan's recall corpus; 197 is Cryben's full finding set. A recall figure over one of them cannot be read against an F1 over another, and earlier versions of this section invited exactly that. Each row belongs to one task and one denominator; none of them combine.

200 clean is not a precision figure. Its denominator is negative files, not emitted findings. It says the scanner stayed silent on 200 files that contain nothing; it says nothing about how many of the findings it does emit are right. Precision is the row two lines above it, and it is the row this tool loses.

The independent control is not published. The Cryben corpus, its scorer and the raw runs behind 36/37, 0.667 and 0.346 are not in this repository. Until they are, those three are this author's claims rather than something a reader can check, and an external comparison said so in those words. Publishing the artefacts is the repair; restating the numbers is not.

Four things those numbers do not mean, said here rather than left to be assumed:

  • Cryben is 37 cases in this tool's scope. A figure from 37 cases has a wide interval. It is a floor worth publishing, not a precision claim.
  • The scorer is theirs, and it credits a finding that names nothing. qscan's metric matches per file, not per line, and 10 of its hits name no algorithm at all. A tool that reports "something cryptographic is here" scores the same on those as one that says which family it is, so detection and family classification have to be published apart. They are not, here, yet. An earlier version of this section claimed the figure was unchanged with that credit withdrawn. It was wrong: the switch that withdrew it dropped only one bucket, while elliptic-curve findings kept the credit unconditionally. Withdrawn properly, and applied to both tools, the numbers are 0.818 for this tool and 0.790 for qscan. The conclusion survived the correction; the sentence did not.
  • A per-scan control is still not held. claims.control.held stays false in your scan unless you run one, and it should: a figure measured here says nothing about your repository.
  • Recall is not the axis this tool is weakest on — precision is. On Cryben's full 197 findings, scored by its authors' own tool, this scanner's precision is 0.667 against qscan's 0.93. It was 0.542 one release ago. Reaching a rival's recall while reporting more that the reference does not is not the same as being the better inventory, and it would be dishonest to publish the first number without the second.

Three levels of evidence, because a quoted line of code is not always safe to send. --level full carries the line itself. --level masked keeps its shape with everything but the algorithm name starred out — *** = rsa.********_*******_***(***_****=****) — so a reader sees a call rather than a string without seeing the contents. --level trimmed carries no line at all. The file and the line number stay in all three.

The masking rule is inverted from the obvious one. A rival tool masks by position, keeping the first characters and starring the rest, which keeps whatever the line happens to begin with — a token, a key, a password. Here only the characters that spell the algorithm survive; every other letter and digit becomes a star, and punctuation stays because the shape of a call is not a secret. An independent analysis of this scanner found a synthetic token sitting on the same line as a finding, which is what prompted the level.

From 0.16.0 the tools mask by default. The level existed on qrp-mcp scan --out -- the path that writes a file for you to read before you send it -- and nowhere else. The scan_repo and export_cbom tools returned every matched line as written, and those are the path that runs on every agent call and hands its result to a model. export_cbom says in its own description that it is for "when the result has to leave the machine", and it carried each line verbatim into evidence.occurrences. The control had been built for the path we thought left the machine rather than the one that leaves on every call. Both tools now take the same level and default to masked; level="full" returns the lines. Reading is unchanged -- masking decides how a finding is quoted, never whether it is found, and no detection figure in this file moves because of it.

qscan is faster: 7.9 s against 219 s on Vault, a factor of 27.7, and a factor of 10.6 on the median of five warm runs over a smaller corpus in an independent comparison. Measured here after 0.13.0: 26.1 s for 823 files of certbot, 31.7 ms per file, of which the bulk is 47 algorithm patterns run over every line -- about 4.5 million pattern searches on that tree. A prefilter would cut it and would also change what is detected, so it is a task of its own rather than a line in this one. The figure was written here as "about 24" and was arithmetic nobody had done: 219 / 7.9 is 27.7. It is not deeper: it is lexical, by its own changelog, as this section already says. Measured per language on its own corpus it leads in none by more than three labels and trails this tool by five in Go; its real edge is in TLS, SSH and dependency lines rather than in any language. This tool reads more kinds of file, says what it did not read, and does not invent a family for an asset that names none.

The corpus that measures the other direction

A corpus of labelled findings can only ever say what a tool misses. It says nothing about the twenty lines in the same file that are not cryptography. So this release is measured against one written for the opposite question:

  • 200 files with no quantum-vulnerable cryptography at all — ordinary code in fifteen languages, configuration, CI, infrastructure, manifests, lockfiles, data and documentation. Any finding on one of them is a false positive.
  • 100 near misses — cryptography named, banned, discussed, tested against or imitated, but not used: an SSLProtocol line removing SSLv3, a policy listing forbidden algorithms, a lint rule quoting the pattern it forbids, a test asserting an algorithm is rejected, a docstring explaining why RSA was dropped, base64 that is a JWT payload rather than a key.

Every file was written and labelled before the scanner was run over it, and the corpus was written by someone who had not read the scanner's rules. A corpus assembled by looking at what a tool reported measures the tool against itself, which is the circularity this document criticises elsewhere.

Result: 200 of the 200 negative files are clean — no finding of any kind. On the 100 near misses, this release reports 83 findings the labels say should not read as a use, down from 136. What remains is one shape: a file whose whole purpose is to forbid. A lint rule quoting hashlib.md5(...) as the thing it bans is, line by line, indistinguishable from code calling it; telling them apart needs file-level context this release does not attempt.

The corpus is a floor, not a measurement. It was written by the same hands that wrote the tool, so a construct nobody here thought of is in neither. Its honest use is as a difference between two releases.

What is still missing here

Stated rather than hidden, and each of these is a known gap rather than a suspicion:

  • A finding carries a kind, not a confidence level. Since 0.11.0 every finding says whether it is a call, an import, a comment, a declaration, a reference or a ban, and 0.12.0 decides that by the position of the match rather than by the line it sits on. What is still missing is a graded confidence. Two contexts that were read as code and are not -- a Python docstring, and a string constant holding a PEM header -- were reported by an independent comparison and are fixed in 0.13.0, with the controls that keep the fix narrow: a key pasted into a triple-quoted value is still key material, and a cipher suite named in a string is still configuration.
  • SSH and TLS assets are read at 8/14 and 7/11 on qscan's corpus; X448 at 4/8.
  • Speed. Reading key material by content costs about 26% over 0.8.1 on a large tree.
  • A symlink is never read. Where a tree reaches content only through a link whose target sits in a directory this tool excludes, that content is not scanned. It is named as a link rather than silently skipped, but it is not read.
  • Not built from the 7 Oct 2026 research, and why. A CDK listener with no sslPolicy (the result depends on a cdk.json feature flag and on CDK inferring the protocol); CloudFront, API Gateway, Transfer Family and OpenSearch policies; open62541's UA_ServerConfig_setDefaultWithSecurityPolicies( (whether the SHA-1 pair is in depends on a build flag); Cisco's authentication … mldsa-sig (seen only in a vendor blog); PAN-OS and Windows IKE fragmentation (no element or key found in a primary source); Junos curly-brace fragmentation { disable; } (only the set form is read); Bouncy Castle's cert.plants classes (known from search snippets only); .sage files, where much CSIDH and SQIsign prototyping lives. Cisco running-configs saved as .txt or without an extension are not read.
  • A size is not checked against the interface it has to fit. A finding names the algorithm — ML-KEM-768, whose ciphertext is 1,088 bytes (FIPS 203), or an ML-DSA signature (FIPS 204) — not whether that fits the link it is configured for: an EAP identity, a single IKE message, a DNSSEC answer over UDP. For most interfaces the ceiling is not in the file, and none is claimed. Two cases are read: IKE fragmentation (above), where the configuration says whether a large message is split, and, from 0.32.0, DNS, where it is in the file — BIND's max-udp-size and edns-udp-size, 1232 bytes when absent (BIND 9 ARM). Even there the size is reported beside the algorithm, not compared with the signed answer: how large an answer gets depends on the zone, and the effective size on the path can be lower than the configured one.
  • What a TLS library defaults to is not stated (from 0.33.0 the absence is): an HAProxy listener without ssl-min-ver or a cipher list says the defaults apply, not which. nginx's proxy_ssl_verify is off by default and only an explicit off is reported. Which TLS library an image carries is known for one image (nginx:1.24.0); a Dockerfile's FROM line is not read as a pin (the file itself is read from 0.35.0), so it is not joined to anything.
  • Symmetric cryptography, hashes for integrity, KDFs and random number generation are out of scope by design. This tool reports what a cryptographically relevant quantum computer would break. On Cryben's full 197 findings — most of which are AES, SHA-256 and KDF — it scores 0.13, and that is the scope working, not failing.

What an outside review found

0.9.0 was reviewed by someone who did not write it, from the published ZIP, and reported nine defects. All nine reproduced here before anything was changed, and all nine are fixed in 0.10.0. Two of them were about the claims this tool makes for itself, which is the worst place to be wrong:

  • A symlink carried the scan outside the directory it was given. A link inside the tree pointing at a file beside it was read, and reported under the link's name. This tool is pointed at code its user did not write, and the excerpt travels into an agent's context and into any exported CBOM, so that was the declared boundary failing. Links are no longer followed. Each is named in symlinks_not_followed, with a relative path when the target is inside the root and the words "outside the scanned directory" otherwise -- printing an outside path would leak what reading it did. A linked directory sets accounts_for_every_file to false. On certbot, which uses 48 of them, coverage falls from 68.9% to 65.8%: the links are now counted as present and not read. No algorithm is lost, because each target is still read at its own path.
  • compare_coverage keyed on a commit, and a commit is not what was read. A dirty emitter still compared; an unverified corpus compared because None read as False; two different subdirectories of one commit compared at 100% and 0% coverage; a git-ignored file that the scan reads changed the corpus while both pins stayed clean. Comparability now keys on corpus.content_digest -- a hash over the files read and the text read from them, the files skipped with their sizes, and every entry not read with its reason. The git pin stays as provenance a reader can follow; it no longer carries the conclusion. dirty is three answers, and None means nobody checked.

The rest: a malformed coverage block returned an exception instead of unestablished; certificate evidence came off a set, so its order moved between interpreters; --out ~/file passed its check and failed its write; and three tests read the tool's own git pins from the environment, so they passed from a clone and failed from the published tarball -- which anyone who downloaded 0.9.0 and ran its tests saw, and we had not, because we always ran in the checkout. Extracting the release and running its tests in a fresh environment is now part of shipping one.

Why deterministic

There is no LLM inside this tool. The same input always produces the same output, and every finding points at a file and a line you can open yourself.

That is the point of handing it to an agent: the agent brings the language, the tool brings the truth. An agent guessing about your signing code is worse than nothing; an agent reading a deterministic inventory can actually reason about it.

What it is not

It reads source, configuration, CI pipelines, infrastructure-as-code, Kubernetes manifests, build files, certificates and key inventories. It does not read documentation, binaries or images.

Every file under the path is accounted for in one of four ways: scanned, unreadable, skipped because the tool does not claim that type — counted by extension, so the coverage figure has a base, and from 0.27.3 named one by one in the coverage block (not_examined → paths) — or a link not followed, named in symlinks_not_followed. files_scanned + unreadable_files + files_skipped_by_type plus the linked files always equals files_present; until 0.34.0 this sentence said three ways and left the links out. A directory the scan cannot enter or list is named in unreadable_directories; its files cannot be counted, so the scan then says it cannot account for every file instead of claiming it read them all. A scan that read seven files out of nine is a different report from one that read seven out of four hundred, and only one of them is worth trusting.

A free inventory tool, not a readiness assessment. It deliberately does not do:

  • risk scoring or prioritisation,
  • migration planning — a replacement names what a finding becomes, not when or in what order,
  • network or host scanning, or reading a system certificate store,
  • tracking change over time — prove_closure compares two scans you give it; nothing keeps a history.

Those live in the Quantum Readiness Platform, the product this tool is extracted from. Nothing here is crippled to push you there — what it does, it does completely.

It also does not tell you that you are about to be hacked. It tells you what you are using.

License

Apache-2.0.

Metadata

Release files for qrp-mcp 0.35.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for qrp-mcp 0.35.0
File Size Uploaded Provenance
qrp_mcp-0.35.0.tar.gz 561.6 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for qrp-mcp 0.35.0
File Interpreter ABI Platform
qrp_mcp-0.35.0-py3-none-any.whl Python 3 none any Details

Total release size: 769.2 kB

Release files / qrp_mcp-0.35.0.tar.gz

Download URL qrp_mcp-0.35.0.tar.gz
Size 561.6 kB
Tags Source
SHA-256 checksum
How to use checksums
916fb30112d5f7dee58c54721ff0a0e11b984861fd3e7e87b92f4ee0016ddc46
BLAKE2b-256 checksum
How to use checksums
f679018b2896eff0d2d3cf5e0bf05d88b5cb3d0ce7cab6c6728137c70a6dcda6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / qrp_mcp-0.35.0-py3-none-any.whl

Download URL qrp_mcp-0.35.0-py3-none-any.whl
Size 207.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8b14cee62a1347152ec605487d898f69b7f4a223eb241a94a7d87e9edf528890
BLAKE2b-256 checksum
How to use checksums
a4b28202de27dee0bc31cc4689f2b6aa698d46223e430027f48373a5a6b3fb2f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Security for qrp-mcp 0.35.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.35.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page