Skip to content

Report cryptographic usage that the noise filter was silently dropping - #3

Merged
thebenignhacker merged 5 commits into
mainfrom
fix/detection-suppression-and-version
Jul 28, 2026
Merged

Report cryptographic usage that the noise filter was silently dropping#3
thebenignhacker merged 5 commits into
mainfrom
fix/detection-suppression-and-version

Conversation

@thebenignhacker

Copy link
Copy Markdown
Member

The v1.4.0 release test failed with five P1 defects. All five are fixed here, together with four P2s from the same pass.

The detection defect

isLowValueContext decided whether a match was low-value by testing the whole source line against about a hundred unanchored substrings, then dropping the finding. Three entries matched ordinary code:

Line Suppressed by
a.md5(b), hashlib.md5(b"a"), return hashlib.md5(d).hexdigest() ".md", meant for Markdown filenames
Cipher cipher = Cipher.getInstance("DES") "cipher ="
const cipher = crypto.createCipheriv(...) "const "

".md" matching every .md5( is the whole explanation for why the bug looked dot-related and MD5-specific: hashlib.sha1 survived only because it contains no .md substring. None of the include flags recovered these, because the match was dropped rather than downgraded. The tool's own crypto-samples/ tree contained deliberately vulnerable code it could not see.

Separately, des.NewCipher produced zero regex matches — a pattern coverage gap, not suppression. \bDES\.(new|encrypt|decrypt)\b cannot match des.NewCipher, because \b after new requires a non-word character.

The replacement

A position-aware evidence classifier (pkg/scanner/evidence.go). It asks whether the matched token is part of an operation — a call, a member access, an import, an argument to a crypto factory — before applying any heuristic, and never suppresses a match that answers yes.

Only three narrative rules remain, and all three are structural: the match belongs to a logging or error call, sits in a comment in a language that has that comment marker, or is inside a URL or document path. Nothing classifies by the words around a match.

Suppression is counted in the summary and recoverable with --include-narrative. The count is computed after deduplication, so it is exactly what the flag adds, and deduplication prefers an operational match over a narrative one so the default report is a strict subset. Detected key material is never withheld.

Also fixed

  • Version provenance. One binary reported 1.4.0, 1.0.0 (SARIF), 1.1.0 (CBOM) and nothing (JSON). A CBOM naming the wrong producer is a false provenance record. All surfaces now read pkg/version; JSON gained a tool object.
  • cryptoscan:ignore blinded the following line. A directive now applies to its own line unless it says -next-line.
  • Nondeterministic finding order from ranging over a map, which broke diffable CI output and reproducible CBOMs.
  • Go DES coverage, colour emoji in output, colour and cursor control not disabled for non-TTY stdout, and em-dashes in scan output.
  • docs/testing/release-smoke.md was the unfilled web-app template telling a tester to run npm ci and check a 375px viewport for a Go CLI.

Behaviour change worth reviewing

Declared configuration is now reported: cipher: DES-CBC, MACs hmac-md5 ..., SSLProtocol +SSLv3, "algorithm": "RSA-2048". For a cryptographic inventory a declared algorithm is the inventory, and an Apache config enabling SSLv3 must fail --fail-on critical. Scanning a repo that ships crypto reference data will report a finding per row; use --exclude.

Verification

Three adversarial review passes were run against this branch. Each found blocking false negatives in the previous revision, and every one is now a regression test:

  • Pass 1 — narrative rules ran before the operational check, so "puts " inside a variable named outputs and -- in Java hid CRITICAL findings. Detection was non-monotonic: a fifth cipher on an sshd_config MACs line took the file from 3 findings to 0. An SSLv3 Apache config flipped --fail-on critical from exit 1 to exit 0.
  • Pass 2 — six "documentation labels" were ordinary config keys, so note: DES-CBC lost a CRITICAL finding. --include-narrative was deleting findings rather than adding them.
  • Pass 3 — quoted-string prose detection hid password_encryption TO 'md5' and eleven other real settings. Both vocabulary-based rules were deleted rather than tuned.

Every regression test was confirmed to fail against the code it guards. The operational suite fails against all four earlier revisions (14 subtests against main; 36, 21 and 13 against the three intermediate attempts) and passes here. A 44-line sweep across nginx, sshd, httpd, my.cnf, php.ini, properties, ini, toml, yaml, json, pom.xml, web.config, Dockerfile, Terraform, Ansible, systemd, SQL and eight languages shows zero regressions against main and ten cases where this build finds crypto main missed entirely. A property test over 200k random line/span pairs found no panic. The emitted CBOM still validates against the upstream CycloneDX 1.6 schema.

Closes the release blockers tracked in todo/roadmap/qramm-cryptoscan-detection-suppression.md.

The scanner decided whether a match was low-value by testing the entire
source line against roughly a hundred substrings and dropping the finding
on any hit. Three of those entries matched ordinary code:

  ".md"      intended for Markdown filenames, matched every ".md5("
  "cipher =" matched Cipher cipher = Cipher.getInstance("DES")
  "const "   matched const cipher = crypto.createCipheriv(...)

So a.md5(b), hashlib.md5(b"a"), const rc4 = CryptoJS.RC4.encrypt(d, k) and
the conventional Java cipher variable all reported zero findings, while
md5(b) reported one. The tool's own crypto-samples tree contained
deliberately vulnerable code it could not see. None of the include flags
recovered them, because the match was dropped rather than downgraded.

Replace the substring blocklist with an evidence classifier anchored to the
position of the match. A match that is part of a call, a member access, an
import or an argument to a crypto factory is reported whatever else appears
on the line. Text mentions (prose, log output, comments, URLs, and the
values of documentation and configuration keys) are still held back, but
the count is reported and --include-narrative shows them. Detected key
material is exempt, since a private key in a comment is still exposed.

Also in this change:

- des.NewCipher and des.NewTripleDESCipher had no pattern at all, so Go DES
  usage was undetectable. This was a coverage gap, not suppression.
- cryptoscan:ignore suppressed the following line as well as its own. A
  directive now applies to its own line unless it says -next-line.
- The version command, SARIF, CBOM and JSON reported 1.4.0, 1.0.0, 1.1.0
  and nothing respectively for the same binary. A CBOM naming the wrong
  producer is a false provenance record. All four now read pkg/version.
- Findings were ordered by Go map iteration, so repeated scans of an
  unchanged tree emitted a different order each time.

Every regression test here was confirmed to fail against the pre-fix code.
The emitted CBOM still validates against the official CycloneDX 1.6 schema.
Found by the pre-push walkthrough against the new release-smoke checklist.

- Colour and the in-place progress line were emitted even when stdout was
  redirected, so a piped or captured scan collected 189 lines carrying ANSI
  escape and erase-line sequences. Colour is now off when stdout is not a
  terminal, NO_COLOR is honoured, and an explicit --no-color still wins.
- Replaced the colour emoji in scan output with text markers. The geometric
  bullets and box drawing that make up the existing layout are unchanged;
  only characters with emoji presentation were substituted. They previously
  survived --no-color.
- Replaced em-dashes in scan output.
- Added credential and key-material patterns to .gitignore.
- Added a property test over random lines and random match spans, since the
  evidence classifier does manual byte indexing and a panic there would abort
  an entire scan.
- Rewrote docs/testing/release-smoke.md, which was still the unfilled web-app
  template telling a tester to run npm ci and check a 375px viewport for a Go
  CLI. It now covers the detection, suppression, provenance and determinism
  properties this release depends on.
An adversarial review of the previous two commits found that the evidence
classifier ran its narrative rules ahead of the operational check, which
reintroduced the same false-negative class the change set out to remove.
Seventeen confirmed regressions against main, all reproduced by hand:

  outputs = Cipher.getInstance("RC4")     "puts " matched inside "outputs "
  inputs = crypto.createHash("md5")       "puts " matched inside "inputs "
  int z=0; z--; Cipher.getInstance("DES") "--" read as a comment in Java
  gpg --cipher-algo 3DES --symmetric      "--" read as a comment in shell
  cipher: DES-CBC                         config key treated as a label
  MACs hmac-md5 ... hmac-sha1-96          whole-line prose test on sshd_config
  SSLProtocol -all +SSLv3 +TLSv1          same, on httpd.conf
  ssh-keygen -t rsa -b 1024 -f id_rsa     same, on a shell script
  exec.Command("sh","-c","openssl dgst -md5 f")  command read as prose
  Nodes["crypto/rsa.GenerateKey"]         Go selector read as a file path

The sshd case was not monotonic: adding a fifth cipher to a MACs line took
the file from three findings to zero. The Apache case flipped an SSLv3
configuration from exit 1 to exit 0 under --fail-on critical. Every one of
these is reachable by an author of the scanned code with a semantics-
preserving edit, which made the scanner steerable by its own input.

Corrections:

- operationalUse runs first, before the log, comment and path rules. This
  ordering is the safety property and is documented as such.
- Call prefixes are matched on identifier boundaries rather than as
  substrings, so "puts" no longer matches "outputs".
- Comment markers are language-specific. "#" is a comment in Python and a
  private field in JavaScript; "--" is a comment in SQL and a decrement in
  Java. An unknown language gets no markers.
- Whole-line prose detection applies only to documentation files.
- Configuration keys naming an algorithm are no longer narrative. For a
  cryptographic inventory a declared algorithm is the inventory.
- A string carrying command flags is a command, not prose.
- Member access does not fire inside a path-like token, so a filename such
  as legacy-md5.md no longer reads as a member call.
- Over-generic factory callees removed; "new" was matching errors.New().

Also corrected: the withheld count was incremented at the point of
suppression, before deduplication and the severity filter, so it overstated
what --include-narrative returns by more than 70% on a documentation tree.
The evidence check now runs last among the filters and the count is computed
against the deduplicated result, so the number the user is shown is exactly
the number the recommended command returns.

All fifteen new fixtures fail against the previous commit and pass now; the
original thirteen still fail against main.
A second adversarial pass found that six of the narrative labels are ordinary
configuration keys, and that the line-anchored branch hands a leading label
the whole rest of the line. Renaming a key by one word hid a CRITICAL finding
that main reported:

  note: DES-CBC        main 1 finding (CRITICAL), this build 0
  remarks: DES-CBC     main 1, this build 0
  comment: DES-CBC     main 1, this build 0
  help/usage/example   main 1, this build 0

`cryptoscan scan --fail-on critical` on `note: DES-CBC` returned exit 0 where
main returned exit 1. comment: is a real key in Kubernetes annotations and GPG
batch files; note: and remarks: are real in inventory YAML. The previous
commit's own header claimed configuration keys were not narrative, and these
six contradicted it.

Also from the same pass:

- A comma was accepted as a key/value separator, so a neighbouring list
  element ending in a label word suppressed the next one:
  `[]string{"key usage", "DES-CBC"}` lost the DES finding. This was already
  firing on this repo's own pattern tests, on `extendedKeyUsage`.
- `--include-narrative` was not a superset. Deduplication keys on
  file:line:category and kept the highest priority, so a withheld match could
  REPLACE a reported one rather than add to it: 4 to 19 findings per tree were
  in the default report but absent from the flag's output, while the summary
  said nothing was withheld. Deduplication now prefers an operational match
  over a narrative one at the same location. Verified as a strict subset on
  four trees, with the count exact on each.
- Nine unreachable entries in commentMarkers removed; detectLanguage cannot
  return sql, lua, haskell, ada, perl, r, makefile, scala or html. The header
  comment justified the design with an example that never executed.
- isDocumentationFile no longer claims html (unreachable) or xml (every .xml,
  including pom.xml and Spring configs, is configuration rather than prose).
- Removed a narrativeCount clamp that could not fire.

Test gaps that let this through, now closed: no case covered a config key
other than `cipher:`, and the withheld-count test compared lengths, which
cannot see a replacement. Added eleven config-key and list-neighbour fixtures
and a set-containment assertion on file:line:column:match:category. The
`url` case in the withheld suite was vacuous, asserting on a line the pattern
matcher never fires on at all; replaced.

The operational suite now fails against all three earlier revisions: 13
subtests against main, 23 against the first attempt, 8 against the second.
A third adversarial pass found 20 more regressions against main, twelve of
them from one rule. Both remaining word-based heuristics are deleted rather
than tuned, because each had already been narrowed twice and kept losing
findings to inputs nobody had thought of.

Quoted-string prose (>=3 words, no dash-flag, one English word) hid real
configuration, since a quoted string is exactly where algorithm names live:

  db.Exec("ALTER SYSTEM SET password_encryption TO 'md5'")      1 -> 0
  props.put("jdk.tls.client.cipherSuites", "RC4 and DES ...")   1 -> 0
  conf.set("hive.ssl.ciphers", "DES-CBC3-SHA and RC4-MD5 ...")  2 -> 0
  q := "SELECT alg FROM certs WHERE alg = 'RSA-1024' AND ..."   2 -> 0
  os.system("openvpn config.ovpn cipher DES-CBC auth SHA1 ...") 2 -> 0

Leading documentation labels owned the rest of the line. The word list was
never the problem; handing a label the rest of the line is. It lost a
CRITICAL finding to `"title": "RSA-1024"`, to `"description" : "DES-CBC"`
with a space before the colon, to `:title => "DES-CBC"`, and to any key
merely ending in a label such as `ssl-title:`.

Both are gone. What is left is structural: a string owned by a logging or
error call, a comment in a language that has that comment marker, and a URL
or document path. None of them look at which words a value contains. The
practical cost is that `description: uses SHA-1 ...` is now reported, which
is the cheaper error.

Also fixed from the same pass:

- "#" opened a comment mid-token, so a URL fragment ended a YAML value:
  `jdbcUrl: jdbc:mysql://h/db?cipher=DES-CBC#legacy` lost its finding. It now
  requires the start of a line or preceding whitespace.
- An assignment did not end a token, so `CHECKSUM=md5.txt` read as a document
  path. "=" is now a token boundary, and txt and pdf are no longer treated as
  document extensions.
- Removed isProseLine, isDocumentationFile and stripMarkup as dead code:
  shouldScanFile skips documentation files unless IncludeDocs is set, and no
  flag or config key ever sets it, so the branch could not run.

Verified across 44 realistic lines spanning nginx, sshd, httpd, my.cnf,
php.ini, properties, ini, toml, yaml, json, pom.xml, web.config, Dockerfile,
Terraform, Ansible, systemd, SQL and eight languages: zero regressions
against main, and ten cases where this build finds crypto main missed
entirely. The operational suite now fails against all four earlier revisions
(14 subtests against main, 36, 21 and 13 against the three attempts).
@thebenignhacker
thebenignhacker merged commit 97de325 into main Jul 28, 2026
4 checks passed
@thebenignhacker
thebenignhacker deleted the fix/detection-suppression-and-version branch July 28, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant