@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +880 -0
  2. package/bin/agentic-security.js +189 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +192 -15
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +15 -15
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +21 -13
  19. package/src/dataflow/CLAUDE.md +12 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/catalog-expanded.js +1 -0
  22. package/src/dataflow/catalog.js +157 -31
  23. package/src/dataflow/engine.js +639 -112
  24. package/src/dataflow/implicit-flow.js +68 -36
  25. package/src/dataflow/incremental.js +18 -3
  26. package/src/dataflow/index.js +17 -1
  27. package/src/dataflow/points-to.js +19 -6
  28. package/src/dataflow/proven-clean.js +41 -0
  29. package/src/dataflow/sanitizer-gate.js +35 -9
  30. package/src/dataflow/sanitizer-proof.js +21 -3
  31. package/src/dataflow/stub-aware-filter.js +36 -13
  32. package/src/dataflow/summaries.js +21 -2
  33. package/src/engine.js +430 -196
  34. package/src/ir/CLAUDE.md +16 -2
  35. package/src/ir/balanced-call.js +55 -0
  36. package/src/ir/class-hierarchy.js +57 -11
  37. package/src/ir/index.js +14 -2
  38. package/src/ir/parser-cs.js +513 -40
  39. package/src/ir/parser-go.js +29 -11
  40. package/src/ir/parser-java.js +300 -20
  41. package/src/ir/parser-js.js +300 -22
  42. package/src/ir/parser-kt.js +436 -18
  43. package/src/ir/parser-php.js +631 -38
  44. package/src/ir/parser-py.helper.py +32 -2
  45. package/src/ir/parser-py.js +31 -4
  46. package/src/ir/parser-rb.js +161 -26
  47. package/src/ir/ssa.js +6 -1
  48. package/src/lsp/server.js +35 -3
  49. package/src/mcp/CLAUDE.md +9 -2
  50. package/src/mcp/redact.js +26 -0
  51. package/src/mcp/tools.js +164 -15
  52. package/src/posture/CLAUDE.md +19 -7
  53. package/src/posture/accuracy-scorecard.js +9 -1
  54. package/src/posture/aibom.js +12 -8
  55. package/src/posture/auditor-walkthrough.js +102 -3
  56. package/src/posture/autopilot.js +8 -1
  57. package/src/posture/calibration-drift.js +11 -5
  58. package/src/posture/calibration.js +24 -2
  59. package/src/posture/clustering.js +12 -1
  60. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  61. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  62. package/src/posture/compliance-policy.js +33 -1
  63. package/src/posture/confidence.js +44 -10
  64. package/src/posture/corpus-enroll.js +9 -5
  65. package/src/posture/corpus-match.js +19 -0
  66. package/src/posture/csharp-analysis.js +62 -3
  67. package/src/posture/deploy-platform.js +4 -1
  68. package/src/posture/drift.js +7 -1
  69. package/src/posture/epss.js +13 -1
  70. package/src/posture/evidence-bundle.js +36 -6
  71. package/src/posture/exploitability-probability.js +13 -1
  72. package/src/posture/falsification.js +23 -2
  73. package/src/posture/fix-metrics.js +1 -1
  74. package/src/posture/fix-verify-loop.js +10 -1
  75. package/src/posture/iac-reachability.js +14 -8
  76. package/src/posture/integrity.js +25 -7
  77. package/src/posture/model-rescan.js +65 -0
  78. package/src/posture/mttr.js +5 -0
  79. package/src/posture/poc-inprocess.js +27 -8
  80. package/src/posture/regression-test-gen.js +23 -8
  81. package/src/posture/reverse-blast-radius.js +5 -1
  82. package/src/posture/risk-dollars.js +18 -1
  83. package/src/posture/sbom.js +2 -2
  84. package/src/posture/secret-history.js +20 -11
  85. package/src/posture/security-trend.js +7 -1
  86. package/src/posture/stack-playbook.js +22 -1
  87. package/src/posture/threat-model-grounding.js +2 -2
  88. package/src/posture/validator-metrics.js +10 -3
  89. package/src/posture/verifier.js +32 -57
  90. package/src/report/index.js +183 -14
  91. package/src/runScan.js +1 -1
  92. package/src/sast/_comment-strip.js +15 -4
  93. package/src/sast/_secret-entropy.js +1 -1
  94. package/src/sast/authz.js +6 -4
  95. package/src/sast/bench-shape/index.js +2 -7
  96. package/src/sast/claude-md-prompt-injection.js +14 -3
  97. package/src/sast/cloud-iam.js +60 -7
  98. package/src/sast/cpp-bench-extras.js +1 -1
  99. package/src/sast/csrf.js +7 -5
  100. package/src/sast/env-hygiene.js +5 -2
  101. package/src/sast/iac-terraform.js +25 -0
  102. package/src/sast/java-bench-extras.js +1 -1
  103. package/src/sast/java-constant-fold.js +5 -5
  104. package/src/sast/llm-owasp.js +4 -2
  105. package/src/sast/mcp-audit.js +7 -0
  106. package/src/sast/pipeline.js +8 -0
  107. package/src/sast/prompt-template.js +8 -6
  108. package/src/sast/prototype-pollution.js +6 -2
  109. package/src/sast/redos-nfa.js +6 -6
  110. package/src/sast/secret-concat.js +13 -2
  111. package/src/sast/ssrf-cloud-metadata.js +6 -3
  112. package/src/sast/xss-reflected-multilang.js +1 -1
  113. package/src/sast/xxe.js +1 -1
  114. package/src/sca/CLAUDE.md +3 -4
  115. package/src/sca/container.js +35 -3
  116. package/src/sca/dep-confusion.js +7 -0
  117. package/src/sca/sarif-ingest.js +0 -187
package/CHANGELOG.md CHANGED
@@ -9,6 +9,886 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.137.0 — detection-gap remediation Themes B+D, C, E, plus R9, the R16 close-out, and the docs overhaul
13
+
14
+ Seven independent slices of `docs/DETECTION_GAP_REMEDIATION_PRD.md` land
15
+ together here (R6, R8, R9, R10, R11, R13, R14(a), R14(b), R16), alongside the
16
+ world-class docs overhaul (`docs/DOCS_OVERHAUL_PRD.md`). Each has its own
17
+ subsection below, and each subsection carries its own verification paragraph —
18
+ the numbers in one do not describe the other.
19
+
20
+ ### Docs overhaul — a learning layer, an accuracy pass, and an anti-rot gate
21
+
22
+ The product had a strong evidence layer (architecture, metrics, compliance
23
+ maps) and no learning layer. This release adds one, and repairs what was false.
24
+
25
+ - **A deliberately-vulnerable demo app** at `examples/demo-app/` — ~10 files
26
+ spanning every pillar (SQLi, missing auth, eval, MD5 hashing, prompt
27
+ injection, hardcoded key, Dockerfile hygiene, vulnerable deps). Its promised
28
+ findings are pinned by `scanner/test/demo-app.test.js` (wired into
29
+ `test:smoke`) so a detector change can't silently make the tutorials lie. It
30
+ is outside the self-scan gate's target set, so it never perturbs that gate.
31
+ - **A 15-minute quickstart** (`docs/guides/quickstart.md`) and **six
32
+ task-oriented how-to guides** — scanning, fixing, SBOM/AI-BOM, compliance, CI
33
+ setup, leaked-secret response — plus a **CLI reference**, a **configuration &
34
+ env-var reference**, and a **docs hub** (`docs/README.md`). Every command
35
+ shown was run against the demo app before being documented.
36
+ - **Accuracy pass** — repaired every false/contradictory claim the doc survey
37
+ found: version drift across four manifests (`gemini-extension.json` was
38
+ ~60 versions stale), the model-cost-optimizer default contradiction, the
39
+ compliance `--gap` row, a skill pointing at a deleted command file (revoke-URL
40
+ matrix restored inline), the README's `hunt`-is-a-slash-command claim, and
41
+ `secure --tour`/`--daily` documented-but-unimplemented (now implemented).
42
+ - **New anti-rot gate** — `scripts/check-doc-drift.mjs --gate` fails on any
43
+ dangling internal link across README/docs/commands/skills/agents; wired into
44
+ the release gate as `doc-links` and proven both directions. Manifest
45
+ version-sync now also covers `gemini-extension.json`.
46
+ - **Two output-correctness fixes surfaced while documenting:** the CycloneDX/
47
+ SPDX SBOM tool version was hardcoded `0.7.0` — now stamped from the real
48
+ engine version via `meta.engineVersion`. And `js-yaml` was bumped
49
+ `5.2.3 → 5.3.0` to clear the dependency-currency gate.
50
+
51
+ Verification: `test:smoke` 30/30 (includes the two demo-app contract tests),
52
+ `sbom` 3/3, `release-check` 49/49, `check-doc-drift --gate` clean and
53
+ fails-on-planted-break. Full `npm test` + the release gate run on push.
54
+
55
+ ### R9 — Java call-graph edges existed in the CFG but never reached the call graph
56
+
57
+ `ir/parser-java.js` never emitted `fn.calls`, leaving every Java function's
58
+ call-graph edges permanently empty (`callgraph.js` reads `fn.calls`
59
+ exclusively). Wired the same shared, language-agnostic call-extraction helper
60
+ six other parsers already use — no new extraction logic, matching the
61
+ identical precedent set by Ruby's earlier fix. A final-review fix wave
62
+ rebuilt the bundle, corrected doc overclaims, and added a resolution proof
63
+ test (`test/parser-java-calls.test.js`, 94 lines).
64
+
65
+ ### R16 — independent population re-measured; the finding is the absence of movement
66
+
67
+ Re-ran `bench/independent` (110 GHSA-labelled entries, fresh fetch, scan
68
+ state wiped) after seven PRD themes landed since the last measurement. Result
69
+ is identical, entry for entry, to the 2026-08-09 run — same TP/FP/FN/TN, same
70
+ per-language split, same recall across all ~40 CWE categories. Reported
71
+ plainly rather than explained away: the independent population has zero
72
+ Java/C#/Kotlin/PHP/Ruby/Go entries, so R8/R9 could not have moved it
73
+ structurally; Theme A (the plan's own hypothesized dominant lever, which also
74
+ landed after the baseline) plus R6/R10/R11/R13/R14(a) could have moved a
75
+ JS/TS/Python entry and none did. This measurement cannot distinguish "fix
76
+ doesn't occur in these 110 entries' shapes" from "effect masked elsewhere in
77
+ the same scan" — only that the net observable outcome per entry is unchanged.
78
+ The PRD backlog is closed on this basis.
79
+
80
+ ### Theme B+D (R6, R10, R11) — semantic grounding and interprocedural completeness
81
+
82
+ Closes three of the five open items in `docs/DETECTION_GAP_REMEDIATION_PRD.md`'s
83
+ Theme B ("semantic grounding of matching") and Theme D ("interprocedural
84
+ completeness"). R7 and R12 — filed under the same two themes — turned out to
85
+ already be landed (commit `553f9a5`, swept in opportunistically alongside
86
+ Theme A's nine fixes).
87
+
88
+ - **Class Hierarchy Analysis is now wired into the deep pipeline** —
89
+ prerequisite infrastructure for R6 and R11. `ir/class-hierarchy.js` and the
90
+ receiver-type heuristic (`dataflow/receiver-context.js`) were both already
91
+ built and unit-tested but never consulted at scan time; `dataflow/index.js`
92
+ now builds CHA once per scan and threads it through every `callContext`.
93
+ Landing this exposed a real, independent pre-existing bug in
94
+ `class-hierarchy.js` itself: its method-qid parser assumed a dot-joined
95
+ `"ClassName.method"` shape, but the parser's actual qid format for a class
96
+ method is `::`-joined (`file.js::ClassName::method@line`) — so `cha.classes`
97
+ was silently empty for every JS/TS class, and CHA-based resolution could
98
+ never have worked at all until this was fixed. The only prior test for
99
+ `buildClassHierarchy` had hand-mocked a qid in the wrong shape, which is why
100
+ this went unnoticed.
101
+ - **R6 — catalog sink matching is now gated by CHA-inferred receiver type.**
102
+ A bare-name sink like `.query()` or `.get()` previously matched on ANY
103
+ receiver project-wide (`cache.query(x)` scored identically to
104
+ `db.query(x)`). An opt-in `match.receiverTypeIn` catalog field is now
105
+ declared on the 5 highest-FP-risk bare-name entries (`js-sql-query`,
106
+ `js-sql-execute`, `py-requests-get` x2, `rb-erb-new`). Unknown receiver type
107
+ never suppresses a match — only a confidently resolved, non-matching type
108
+ does.
109
+
110
+ **Coverage is not uniform across those 5, and the honest summary is that
111
+ only the two JS entries do real work.** The gate can only fire when CHA
112
+ actually resolves a receiver type, and CHA's `typeOfVar` is populated from
113
+ exactly one shape: a local `let/const x = new Foo()` whose IR carries the
114
+ `isNew` marker — emitted today by the JS/TS, Java and C# parsers only.
115
+ So `rb-erb-new` is effectively inert: `ERB.new(x)`'s receiver is a bare
116
+ identifier that is never `new`-assigned, so the type is always unknown and
117
+ the entry always stays permissive. The two `py-requests-get` entries are
118
+ inert for the same reason (Python has no `new`, so its parser emits no
119
+ marker). Both are harmless — an inert gate is a permissive gate, and the
120
+ pattern layer's match survives untouched — but "applied to 5 entries"
121
+ should not be read as "gating 5 entries."
122
+ - **R10 — a call nested inside another expression now consults the callee's
123
+ own taint summary.** `sink(getUserInput())` previously only checked
124
+ `getUserInput()`'s own arguments for taint (the call's return-taint was
125
+ invisible outside assignment-RHS and bare-statement position, the only two
126
+ places the summary cache was consulted). `exprTaint`'s `'call'` case now
127
+ also resolves and consults the callee's summary, via the same shared
128
+ resolver R11 uses.
129
+ - **R11 — a JS/TS member call (`svc.save(x)`) now resolves interprocedurally
130
+ when CHA traces the receiver to one unambiguous, assignment-tracked local
131
+ variable.** Previously refused unconditionally (a bare dotted-name guess
132
+ risks inventing an edge between two unrelated same-named methods). This
133
+ landed narrower than originally scoped: it deliberately still refuses
134
+ `this.field.method()` resolution. An early implementation reused R6's full
135
+ receiver-type heuristic, including its two name-guess fallbacks
136
+ (`this.field` PascalCase-to-class guessing, bare-identifier soft-labeling) —
137
+ safe for R6's weaker consequence (mis-gating an *existing* catalog match),
138
+ but review found that reusing the same guesses for R11's stronger
139
+ consequence (fabricating a *new* interprocedural call-graph edge) let a
140
+ same-named unrelated variable resolve to the wrong class purely by name
141
+ coincidence. R11 now calls `classOfVar` directly, trusting only genuinely
142
+ assignment-tracked local types, and an ambiguous or unresolved receiver
143
+ (including every `this.field` shape) still safely refuses to resolve rather
144
+ than guessing — matching this PRD's own stated caution that R11 should stay
145
+ unimplemented rather than ship with degraded precision.
146
+
147
+ Two narrower gaps surfaced during R11 implementation and were deliberately
148
+ left unfixed as out of scope (recorded as candidate future work in
149
+ `docs/DETECTION_GAP_REMEDIATION_PRD.md`'s new "Status updates" section): CHA's
150
+ variable-type tracking is scoped to the exact enclosing function (a
151
+ module-scope instance referenced from inside a closure/route-handler can't be
152
+ typed there), and taint-argument recognition only handles bare-identifier or
153
+ one-level member-access call arguments (a two-level access like
154
+ `req.query.cmd` passed directly is invisible to it).
155
+
156
+ - **A wiring-and-verification pass on this work caught R6 suppressing a real
157
+ finding, and the fix landed the same way it was found: with a gate.**
158
+ `bench:layer-recall:check` — which exists precisely to catch a layer going
159
+ quiet on a language it used to cover — flagged `js/ts` taint recall
160
+ dropping 7 → 6. Root cause: `_receiverTypeFor` fell back to returning the
161
+ receiver's own bare identifier name (e.g. `c`) whenever `classOfVar`
162
+ couldn't verify a type, and the caller then treated that name as a
163
+ confidently-resolved non-match rather than as "unknown" — a direct
164
+ violation of R6's own "unknown != clean" rule. Concretely:
165
+ `const c = mysql.createConnection({}); c.query(tainted)` (a `mysql`
166
+ connection assigned via a factory call rather than `new X()`, the exact
167
+ shape `CVE-2021-22214-node-sqli-shape` exercises) was silently dropped by
168
+ the taint engine, because `'c'` doesn't match the SQL receiver allow-list —
169
+ even though it's a genuine, tainted SQL sink. `bench:cve-replay:check`
170
+ stayed green throughout, because a different, non-taint layer happened to
171
+ still catch this same corpus entry — the corpus gate answers "was it
172
+ detected at all," not "by which layer," which is exactly the blind spot
173
+ `bench:layer-recall:check` exists to close. Fixed by removing the
174
+ bare-identifier fallback: a non-`this` receiver is now trusted only when
175
+ `classOfVar` genuinely resolves it, mirroring the fix already applied to
176
+ R11 above. This is exactly the kind of near-miss the full gate sequence
177
+ (test, corpus, mutation, layer-recall) exists to catch before it ships, and
178
+ it did.
179
+ - **That fix was too narrow, and the whole-branch review caught it: the same
180
+ bug class had three more instances in the same function.** The round-4 fix
181
+ above removed the bare-identifier fallback but kept the `this.field`
182
+ PascalCase guess, on the reasoning that it wasn't implicated in *that*
183
+ regression. It was implicated in the identical one. `receiverTypeAtCall`'s
184
+ `this`-branch structurally cannot return `null` — it PascalCases the field
185
+ name and returns it — so every `this.<field>.method()` call was treated as a
186
+ confidently-resolved type, and only field names that happened to collide
187
+ with the allow-list vocabulary survived. `this.dbConn.query(req.query.q)`
188
+ and `this.readReplica.query(req.query.q)` are both real SQL injections, both
189
+ reported by the pre-branch base commit, and both were silently absent from
190
+ this branch — not demoted, gone, with no other layer catching them. Two more
191
+ instances alongside it: for a multi-segment chain like `svc.db.query(x)` the
192
+ code resolved `parts[0]` — the chain ROOT — answering "what type is `svc`?"
193
+ when the receiver is `svc.db`, a property path CHA never types at all; and
194
+ `buildClassHierarchy`'s `typeOfVar` walker accepted any PascalCase callee as
195
+ a constructor, so `const q = BuildCache()` was confidently mistyped as class
196
+ `BuildCache`. All three were name-or-shape guesses being trusted as
197
+ resolutions.
198
+
199
+ Rather than a fourth one-off patch, `_receiverTypeFor` now states the one
200
+ thing CHA can actually verify and refuses everything else: a receiver chain
201
+ of exactly two dot-separated parts (`x.method`), resolved through
202
+ `classOfVar`. `this`-rooted and multi-segment chains return `null` —
203
+ unknown, permissive. `parser-js.js` now emits the `isNew` marker on
204
+ `NewExpression` (matching what the Java and C# parsers already emit) and
205
+ `buildClassHierarchy` requires it, so a PascalCase *factory* call is no
206
+ longer mistaken for a constructor. Separately, the `receiverTypeIn`
207
+ vocabularies were exact-anchored (`^(?:db|pool|conn…)$`) from back when the
208
+ value reaching them could be a bare variable name; now that only real class
209
+ names arrive, `DatabaseConnection`, `PrismaClient` and `MySQLConnection` all
210
+ failed the allow-list and were suppressed, while only a class literally
211
+ named `Db` passed — the existing test passed solely because its fixture was
212
+ named `class Db`. Those four patterns are now substring matches
213
+ (`rb-erb-new`'s `^ERB$` stays anchored: one exact class, not a vocabulary),
214
+ and `Cache` still correctly suppresses.
215
+
216
+ The claim two bullets up — "unknown receiver type never suppresses a match"
217
+ — was false on the `this.field` path for the whole of this branch's life
218
+ until now. It is true again, and it is now gated rather than asserted:
219
+ `bench/mutation/` gained a detection dimension and four R6 cases, two of
220
+ them metamorphic renames (`class Db` → `class DatabaseConnection`,
221
+ `this.db` → `this.dbConn`) that a vocabulary-keyed gate cannot survive, plus
222
+ an adversarial non-DB receiver so that simply deleting the gate cannot pass
223
+ either. Both metamorphic cases fail on the pre-fix engine. Three rounds of
224
+ this same false-negative class shipped behind human review; the mutation
225
+ gate is what makes a fourth fail loudly instead.
226
+
227
+ **Verification — Theme B+D (R6, R10, R11) only:** full test gate green
228
+ (`npm test`, 3146 tests), corpus (214/214, no drift), mutation (9/9
229
+ verdict-flip, and non-zero exit confirmed against the pre-fix engine) and
230
+ layer-recall (214/214 detected, per-language taint counts equal to baseline)
231
+ all green. These figures predate the R13 work below, which was gated
232
+ separately; see R13's own verification paragraph for the current totals.
233
+
234
+ ### Theme E (R13) — flow-modeling coverage, both sub-fixes
235
+
236
+ - **R13(a) — a member-write assignment target (`el.innerHTML = tainted`) is
237
+ now consulted against the sink catalog.** The taint engine previously only
238
+ checked call expressions against the sink catalog; a plain property
239
+ assignment with no call syntax at all — the PRD's own success metric,
240
+ `el.innerHTML = req.query.x` — was structurally invisible regardless of
241
+ taint. `dataflow/catalog.js` gains a small member-write sink table and
242
+ `matchMemberWriteSink(targetPath, file)`; `dataflow/engine.js` consults it
243
+ on assignment targets alongside the existing call-sink path.
244
+ - **R13(b) — a for-of loop variable now carries the iterated expression's
245
+ taint into the loop body.** `for (const item of req.body.items) { eval(item) }`
246
+ — the PRD's other stated success metric — previously read `item` as clean:
247
+ the shared Babel loop visitor never bound the for-of loop variable to what
248
+ it iterates. `ir/parser-js.js`'s loop visitor now synthesizes an
249
+ `item = <iterated expr>` assignment in its `enter()` hook, scoped strictly
250
+ to `ForOfStatement`; the other four loop-statement types that funnel
251
+ through the same shared visitor (`for`, `while`, `do-while`, `for-in`) are
252
+ pinned byte-identical in CFG output by a dedicated regression test, since
253
+ a shared-visitor edit is the single riskiest shape of change this plan
254
+ made.
255
+
256
+ **This one took three extra fix rounds, and all three are worth recording
257
+ honestly.** First: a second, generic Babel visitor
258
+ (`VariableDeclarator`) also fires for the for-of binding's own
259
+ `const item` declarator and runs after the loop visitor's `enter()` but
260
+ before the body, silently overwriting the just-synthesized assignment
261
+ with `source:unknown` — fixed with a guard skipping that declarator.
262
+ That guard's first version was over-broad: it skipped *any*
263
+ `ForOfStatement` `left` declarator, which also deleted the same visitor's
264
+ pre-existing destructuring taint-KILL nodes and regressed
265
+ `for (const {cmd} of SAFE) eval(cmd)` to a false positive (the
266
+ destructured `cmd` should shadow and clear an outer tainted `cmd` of the
267
+ same name, and briefly stopped doing so). Narrowed to
268
+ `path.node.id?.type === 'Identifier'` so only the simple-identifier shape
269
+ the loop visitor actually synthesizes for is skipped; destructuring falls
270
+ through unaffected, now pinned by a regression test.
271
+
272
+ Second, and unrelated to the guard bug: R13(b)'s new taint capability made
273
+ a genuinely pre-existing, independent bug newly reachable inside the
274
+ scanner's *own* `ir/type-stubs.js` — `catalog.js`'s `js-exec` entry
275
+ matches any `X.exec(tainted)` by bare property name with no receiver-type
276
+ check, so `RegExp.exec()` calls newly carrying taint via the for-of fix
277
+ got misidentified as `child_process.exec` command injection. Confirmed
278
+ independent of the loop change (reproduces on a trivial non-loop fixture)
279
+ and traced to a catalog entry that predates this PRD entirely
280
+ (`f0d7e03`). Required a `bench/self-scan/BASELINE.json` update
281
+ (`dataflow/index.js: 0→5`, `ir/type-stubs.js: 6→10`), not a code fix —
282
+ logged as its own open gap in `docs/DETECTION_GAP_REMEDIATION_PRD.md`
283
+ rather than patched here, since the real fix needs CHA to type
284
+ regex-literal-assigned variables first.
285
+
286
+ Third, found by the final whole-branch review: narrowing the guard fixed
287
+ destructuring but left the shape the guard now *owns* with no kill at all.
288
+ `const`/`let` in a for-of head is a **block-scoped** binding, and this
289
+ engine's taint model has no block scoping — so once the loop variable was
290
+ bound to the iterable's taint, that state flowed straight past the loop's
291
+ exit and over-tainted a same-named OUTER variable:
292
+ `let item = 'safe'; for (const item of req.body.items) {} eval(item)`
293
+ reported a Code Injection finding that the pre-R13 engine correctly called
294
+ clean, because the generic `VariableDeclarator` visitor used to emit a
295
+ taint-KILL there and the guard suppresses it. That directly violated this
296
+ work's own "strictly additive — never remove or alter an existing finding"
297
+ constraint. The loop visitor now records the bound name in `enter()` and
298
+ re-emits the kill in `exit()`, on the loop's normal exit edge
299
+ (`header → exit-noop → kill → post-loop code`), so in-loop taint
300
+ reachability is untouched and only the post-loop read is cleared. The
301
+ bare-assignment form (`for (x of ...)`, no `const`/`let`) deliberately gets
302
+ **no** kill — that binding is function-scoped and its value legitimately
303
+ survives the loop; killing it would itself have been a regression. Both
304
+ directions are now pinned by tests.
305
+
306
+ Both sub-fixes are covered end-to-end and at the unit level by
307
+ `test/member-write-and-loop-taint.test.js` (12 tests), wired into
308
+ `test:dataflow`.
309
+
310
+ **Verification — R13 only:** full gate green — `npm test` (3158 tests, 0
311
+ failures), corpus (214/214, no drift), mutation (12/12 mutant verdicts
312
+ correct, of which 9/9 are verdict-flip cases), layer-recall (js/ts taint recall unchanged at 7/38 vs. baseline's 7/36 — R13
313
+ lands via dedicated unit tests rather than new corpus entries, so no
314
+ taint-layer increase was expected or observed here) and self-scan (green
315
+ against the baseline this same work already updated).
316
+
317
+ ### Theme E (R14(b)) — non-JS top-level IR
318
+
319
+ Closes the other half of Theme E's R14 item: Python (CST parser and regex
320
+ fallback), PHP, and Ruby now synthesize a `<module>` function wrapping
321
+ top-level statements, mirroring the JS `<module>` pattern
322
+ (`ir/parser-js.js:264,557`) that already existed. Before this, a flat
323
+ vulnerable script with no wrapping function or class — `<?php
324
+ system($_GET['cmd']);`, a bare `system(params[:cmd])` in Ruby, a bare
325
+ `os.system(request.args)` at Python module scope — had zero Layer-2
326
+ taint-analysis coverage in these three languages, regardless of how
327
+ obviously tainted the flow was, simply because the IR layer never extracted
328
+ top-level statements into any CFG at all. Unlike JS's unconditional
329
+ `<module>` creation, all three new paths only synthesize the function when
330
+ the file actually has top-level statements worth lowering, to keep the
331
+ change's blast radius on existing function-only fixtures at zero.
332
+
333
+ Landing this also surfaced (and fixed) a real, independent severity bug:
334
+ the dead-code demotion in `dataflow/engine.js` only exempts functions whose
335
+ name matches `/handler|route|controller|middleware|endpoint/i` from being
336
+ downgraded one severity tier when the call graph records no caller — but a
337
+ synthetic `<module>` function is *never* called by anything (module scope
338
+ has no caller by construction), so every finding this work would have added
339
+ was about to land one tier too low (critical → high, etc.) the moment it
340
+ shipped. `<module>`-scoped findings are now exempt from dead-code demotion
341
+ outright. **This is a severity-tier fix for existing JS `<module>` findings
342
+ too** — nothing new is detected by it, but any JS top-level finding that was
343
+ previously silently demoted now reports at its correct severity.
344
+
345
+ End-to-end coverage: `test/r14b-module-level-e2e.test.js` runs a real
346
+ `runScan` against a minimal flat script in each of the three languages
347
+ (plus Python's regex-fallback path separately) and asserts an `IR-TAINT`
348
+ finding comes back — proving the PRD's actual success metric, not just
349
+ correct IR shape. Wired into `test:dataflow`.
350
+
351
+ **Verification — R14(b) only:** `test:dataflow` (625/625, includes the 4 new
352
+ end-to-end tests), `npm test` (3176 tests, 0 failures on an isolated rerun —
353
+ two transient `spawnSync`-timeout failures surfaced under heavy parallel
354
+ system load on the first two attempts, in `audit-cli.test.js` and
355
+ `triage-command.test.js`, neither of which this work touches, and both
356
+ cleared on rerun), corpus (214/214, no drift), mutation (9/9 verdict-flip
357
+ correct), layer-recall (no taint-layer regression; python/php/ruby taint
358
+ counts unchanged from baseline, as expected — this work lands via dedicated
359
+ unit tests, not new corpus entries) and self-scan (no drift).
360
+
361
+ ### Theme E (R14(a)) — annotation/decorator-shaped framework sources
362
+
363
+ Closes the other half of Theme E's R14 item, left open when R14(b) landed.
364
+ Framework sources expressed as parameter annotations/decorators — Spring's
365
+ `@RequestParam`/`@PathVariable`/`@RequestBody`/`@RequestHeader`, ASP.NET
366
+ Core's `[FromQuery]`/`[FromBody]`/`[FromForm]`/`[FromRoute]`/`[FromHeader]`,
367
+ NestJS's `@Query()`/`@Body()`/`@Param()`/`@Headers()` — had no catalog
368
+ representation at all: the catalog only matched callables and member reads,
369
+ and an annotation is neither. A controller method whose only taint source
370
+ was a decorated parameter was invisible to deep mode regardless of how
371
+ directly it flowed to a sink.
372
+
373
+ A new `annotation` catalog match kind (`dataflow/catalog.js`) is now
374
+ consulted at every one of the taint engine's 8 `analyzeFunction` entry
375
+ points via `_unionAnnotationTaint` (`dataflow/engine.js`), against a new
376
+ IR side-channel field, `fn.paramAnnotations`, populated by three language
377
+ extractors: `ir/parser-cs.js` (C#/ASP.NET Core attributes), `ir/parser-js.js`
378
+ (NestJS decorators), and `ir/parser-java.js` (Spring annotations). The Java
379
+ extractor also fixes a genuine, independent gap-fill that came bundled with
380
+ the annotation work: Java parameter names were never extracted at all
381
+ before this (`params: []` unconditionally) — real parameter names and
382
+ Spring annotations are now both pulled from the same `formalParameterList`
383
+ CST walk.
384
+
385
+ **Accepted false-positive risk, documented rather than silently shipped:**
386
+ matching is on the *bare* decorator/attribute name only (`ANNOTATION_INDEX`
387
+ is keyed by `pa.decorator`, `dataflow/catalog.js`'s `matchAnnotationParams`)
388
+ — there is no import-binding or namespace/package check confirming the
389
+ decorator actually came from Spring/ASP.NET Core/NestJS. A user-defined
390
+ decorator or attribute that happens to share one of these names (a custom
391
+ `@Query()` in an unrelated JS library, a hand-rolled `[FromHeader]`
392
+ attribute) would be treated as a tainted parameter source. This is the same
393
+ risk class R6 (`docs/DETECTION_GAP_REMEDIATION_PRD.md`) already accepted and
394
+ documented for bare-name sink matching before it grew a `receiverTypeIn`
395
+ companion gate; R14(a) has no equivalent gate yet, and none of the three
396
+ extractors have the type/import information available to build one today.
397
+ Left as a known, accepted gap rather than blocking the whole feature on it.
398
+
399
+ Per-task summary: Task 1 (catalog schema) needed one fix round (a missing
400
+ provenance filter, a latent bug). Task 2 (engine plumbing across all 8
401
+ `analyzeFunction` call sites) needed two: round 1's own review found the
402
+ wiring solid but test coverage only jointly proved 2 of 8 sites, and while
403
+ closing that gap it also made two wrong "impossible to isolate" claims about
404
+ two further sites that the re-review refuted with real repros and round 2
405
+ fixed properly. Two genuinely pre-existing, unrelated bugs were found along
406
+ the way and logged in the PRD rather than fixed: a class-field cross-taint
407
+ pass that has been dead code since v0.66.0, and a cross-file finding
408
+ line-number mis-attribution bug. Task 3 (C# extraction) needed one fix round
409
+ (stacked attributes on one parameter only captured the first) and surfaced a
410
+ third data point for the PRD's own R8 item (parenthesized attribute
411
+ arguments break the pre-existing C# method-detection regex), logged not
412
+ fixed. Task 4 (JS/TS extraction) needed one fix round (a defaulted-parameter
413
+ decorator was silently dropped by a type-check bug). Task 5 (Java extraction
414
+ + real parameter extraction) needed one fix round (fully-qualified
415
+ annotations recorded the wrong decorator name); its own review specifically
416
+ investigated whether Java's dropped varargs parameters could cause
417
+ positional param/annotation misattribution and confirmed they cannot, for
418
+ any code that actually compiles.
419
+
420
+ Task 6 (this entry) ran the full verification gate rather than trusting each
421
+ task's own scoped tests, and it earned its keep twice over — two genuinely
422
+ new, real issues, both fixed, neither papered over.
423
+
424
+ First, the full `npm test` run (not exercised by any single task in
425
+ isolation) surfaced a real gap: the four new NestJS catalog entries
426
+ (`js-nestjs-query`, `js-nestjs-body`, `js-nestjs-param`, `js-nestjs-headers`)
427
+ were missing the `provenance` label every JS source entry is required to
428
+ carry (`test/phase7-extensions.test.js`, scoped to `test:sast`, which none
429
+ of Tasks 1-5's own isolated `test:dataflow` runs exercised). Fixed by adding
430
+ the same provenance values already used for the equivalent Express `req.*`
431
+ sources (`url-param`/`http-body`/`path-param`/`header`).
432
+
433
+ Second, `bench:self-scan:check` flagged a brand-new finding in
434
+ `ir/parser-cs.js` itself: Task 3's new `attrRegex` had two independent
435
+ `\s*` quantifiers both able to consume the same whitespace run when the
436
+ overall match fails (no closing `]`) — a textbook adjacent-quantifier
437
+ ReDoS. Verified as a genuine vulnerability, not a detector false positive,
438
+ by direct timing measurement, end-to-end reachable through
439
+ `parseCSharpFile` on an adversarial `.cs` file, not just an isolated
440
+ microbenchmark: 2.4 seconds on a 64,000-character input, extrapolating to
441
+ roughly ten minutes at 1MB.
442
+
443
+ The first fix round moved the leading `\s*` inside the optional
444
+ parenthesized-argument group — genuinely linear (re-verified: 0.49ms at
445
+ 200,000 chars) — but the engine's own `safe-regex`-backed ReDoS heuristic
446
+ still flagged that version, so that round accepted a
447
+ `bench/self-scan/BASELINE.json` bump (`ir/parser-cs.js: 3→4`) as the
448
+ resolution. A task review caught that this repo already has precedent for
449
+ a cleaner fix to the exact same situation: commit `6bd394c`
450
+ (`class-hierarchy.js`) hit an identical "safe-regex flags a pattern that's
451
+ actually safe" case and resolved it by restructuring into two
452
+ independently-safe alternatives rather than accepting the drift, reasoning
453
+ explicitly that this "avoids relying on any one detector's judgment call."
454
+ Applying that same pattern here — two alternatives (no-args and
455
+ with-args) instead of one optional group, decorator name read from
456
+ `match[1] || match[2]` — passes `safe-regex` as `true`, independently
457
+ re-verified linear (0.63ms at 256,000 chars), and produces byte-identical
458
+ matches across a 12-shape sweep against the first round's already-fixed
459
+ version. **No baseline bump was needed after all**:
460
+ `bench/self-scan/BASELINE.json` was reverted to its pre-Task-6 state
461
+ (`ir/parser-cs.js: 3`, `scanner/src` total 621) once the restructured
462
+ regex stopped tripping the heuristic.
463
+
464
+ **Verification — full gate re-run after all fixes:** `test:dataflow`
465
+ 670/670. `npm test` — all 12 scoped sub-scripts report `fail 0`
466
+ (`test:smoke` 28, `test:glob`, `test:sast` 553, `test:posture` 1330,
467
+ `test:dataflow` 670, `test:mcp` 102, `test:report` 111, `test:bench-modules`
468
+ 70, `test:lifecycle`, plus C++-dataflow and Python suites), no `npm error`
469
+ anywhere in the run. `bench:cve-replay:check` 214/214 baselined entries, no
470
+ drift. `bench:mutation:check` 9/9 verdict-flip correct (5/5 metamorphic
471
+ hold, 4/4 adversarial flip). `bench:layer-recall:check` reports no
472
+ taint-layer recall regression across any language — expected, since R14(a)
473
+ lands via dedicated unit tests, not new corpus entries, matching R13's and
474
+ R14(b)'s own precedent. `bench:self-scan:check` clean with **zero drift
475
+ from the pre-Task-6 baseline** — the ReDoS false positive is gone rather
476
+ than accepted, matching commit `6bd394c`'s own outcome exactly.
477
+ `test/parser-cs-annotations.test.js` 5/5 after the restructure. Bundle
478
+ rebuilt (`dist/agentic-security.mjs` + `.sha256`) after each regex change;
479
+ `npm run smoke` against the rebuilt bundle correctly reports critical/high
480
+ findings on the deliberately-vulnerable fixture (exit code 3, this CLI's
481
+ documented "critical findings present" convention), and the underlying
482
+ `test:smoke` suite (28/28) already passed as part of the full gate above.
483
+
484
+ **Full Theme E (R13 + R14) is now complete.**
485
+
486
+ ### Theme C (R8) — braced control-flow body recursion, four languages
487
+
488
+ Closes the single highest-leverage IR defect this project's whole
489
+ detection-gap audit found. Java, C#, Kotlin, and PHP's statement splitters
490
+ previously dropped or mangled the body of any braced control-flow statement
491
+ (`if`/`for`/`try`/`switch`/`while`/`do`/`when`) — not just losing branch
492
+ structure, but silently deleting the statements inside, or folding them into
493
+ a bogus node. Since real-world sinks in these languages overwhelmingly sit
494
+ inside exactly this shape (try-with-resources in Java, `using`/`try` in C#,
495
+ `try`/`when` in Kotlin, `try`/`foreach` in PHP), this capped deep-mode taint
496
+ recall near zero for all four regardless of any catalog or interprocedural
497
+ work already landed elsewhere in this PRD. All four now recurse into these
498
+ bodies with a real (statement-linear) CFG walk.
499
+
500
+ - **Java** (`ir/parser-java.js`) — `walkStmts` additively extended to also
501
+ recurse into `for`/`try`/`switch`/`do`/bare-block statements. Fixed a
502
+ self-caught bug in the implementation plan's own draft code before it
503
+ shipped: try-with-resources' `catch`/`finally` were being read off the
504
+ wrong CST node, which would have silently dropped catch/finally for the
505
+ single most idiomatic JDBC pattern. One fix round closed two further
506
+ gaps: enhanced-for's loop variable now carries real taint provenance
507
+ (synthesized assign, mirroring `parser-js.js`'s `ForOfStatement`
508
+ pattern), and Java 14+ arrow-form `switch` (`case 1 -> …`) is now
509
+ recognized.
510
+ - **PHP** (`ir/parser-php.js`) — the statement splitter now flushes on a
511
+ closing `}` (previously only `;`), with `try`/`switch` recognizers and a
512
+ recursion guard added. This was the hardest task in the plan (3 fix
513
+ rounds, all substantially about line-number precision, not detection
514
+ shape) — see `docs/DETECTION_GAP_REMEDIATION_PRD.md`'s R8 status entry
515
+ for the full round-by-round narration. Fixing the splitter this way also
516
+ resolved a pre-existing bug where `if`/`while`/`foreach` bodies were
517
+ already being mis-split before this task touched them.
518
+ - **C#** (`ir/parser-cs.js`) — C# had no control-flow handling at all
519
+ before this; `_buildCfg` was built from scratch, ported from
520
+ `parser-cpp.js`'s proven recurse-into-braces pattern. One fix round:
521
+ `using (...) { }` and `lock (...) { }` bodies were invisible — `using`
522
+ being the canonical ADO.NET wrapper around exactly the sinks this task
523
+ targets, this was a real, significant gap.
524
+ - **Kotlin** (`ir/parser-kt.js`) — new recursive `_buildCfg` mirroring C#'s,
525
+ with Kotlin-specific adaptations for its trailing-lambda call syntax and
526
+ `when` expression arms. Zero fix rounds during implementation — the
527
+ cleanest of the four tasks, in part because each implementer was briefed
528
+ on the previous tasks' hard-won lessons before starting.
529
+
530
+ **Verification found and fixed a genuine regression this PRD's own new code
531
+ introduced**, not papered over: `bench:self-scan:check` flagged
532
+ `ir/parser-kt.js`'s new trailing-lambda regex as a ReDoS — the identical
533
+ defect class as this same changelog's R14(a) C# `attrRegex` finding (an
534
+ optional group sandwiched between two `\s*` quantifiers), independently
535
+ confirmed genuinely quadratic by direct timing. Fixed the same way that
536
+ precedent was: restructured into two mutually-exclusive alternatives,
537
+ re-verified linear and byte-identical across a 15-shape sweep. A second,
538
+ unrelated ReDoS in the same file's variable-declaration regex was found
539
+ during that investigation and traced to a commit five months predating this
540
+ PRD — left unfixed and logged as a candidate future item, since it wasn't
541
+ introduced by this work.
542
+
543
+ **Measured `bench/layer-recall` impact: 0 of 4 languages — an honest
544
+ result, not the one originally expected, and corrected once more after
545
+ this entry's first draft mis-attributed PHP's gain.** Baseline before:
546
+ `java 1/25, kotlin 0/20, c# 1/21, php 1/23`. Measured after: `java 1/25
547
+ (unchanged), kotlin 0/20 (unchanged), c# 1/21 (unchanged), php 2/23 (+1)`.
548
+ PHP's `+1` is real, but it is **not R8's** — commit-swap A/B testing shows
549
+ the pre-R8 `parser-php.js` still reproduces 2/23, while the pre-R14(b)
550
+ `parser-php.js` (an earlier, unrelated PRD item — PHP's `<module>`
551
+ top-level lowering) drops it back to 1/23. None of the corpus's `pre/`
552
+ fixtures — the state the benchmark actually scores — place a sink
553
+ genuinely inside a control-flow body for any of the four languages this
554
+ task touched. The underlying capability this task fixed is completely real
555
+ and independently proven, just not by this corpus: each language's own new
556
+ dedicated `runScan` unit test (`test/parser-{java,cs,kt,php}-control-flow.test.js`)
557
+ directly asserts a sink nested inside an `if`/`try`/`for`/`switch` body is
558
+ now detected where it wasn't before. `bench/layer-recall/baseline.json`
559
+ still updated to the measured counts (`entriesScored` 210→214, `php` 1→2,
560
+ plus an unrelated `js/ts` 7→8 catch-up from this baseline file not having
561
+ been regenerated since 2026-08-11 — no language decreased) — the baseline
562
+ update is correct regardless of attribution, since it reflects the
563
+ engine's actual current state. A follow-up item: this corpus needs a
564
+ fixture per language with a sink genuinely inside a control-flow body (not
565
+ a guard clause) before it can measure R8's impact at all.
566
+
567
+ **Full gate:** `test:dataflow` 725/725 (rerun clean after the ReDoS fix).
568
+ `npm test` — all 12 scoped sub-scripts green (`test:smoke` 28, `test:glob`
569
+ 13, `test:sast` 553, `test:posture` 1330, `test:dataflow` 725, `test:mcp`
570
+ 102, `test:report` 111, `test:bench-modules` 70, `test:lifecycle` 216,
571
+ `test:eval` 23, `test:discovery` 79, C++-dataflow 26, plus the Python script
572
+ suite). `bench:cve-replay:check` 214/214, no drift. `bench:mutation:check`
573
+ 9/9 verdict-flip correct. `bench:self-scan:check` clean with zero drift
574
+ from the pre-R8 baseline after the fix.
575
+
576
+ ## 0.136.10 — detection-gap remediation Theme A: dedup, family, and calibration for deep mode
577
+
578
+ An architectural audit of the SAST/taint pipeline (`docs/DETECTION_GAP_REMEDIATION_PRD.md`)
579
+ found nine structural gaps behind missed real-world vulnerability classes. This
580
+ release lands Theme A, the fixes on the production detection path:
581
+
582
+ - **Dead-code demotion silently downgraded nearly every finding.** A field-name
583
+ mismatch (`.to`/`.size` vs the real `.callee`/Array shape) meant `calledQids`
584
+ was always effectively empty, so any function not named `handler`/`route`/
585
+ `controller`/`middleware`/`endpoint` — including most real sinks — got
586
+ demoted one severity notch on every deep-mode scan that ever ran.
587
+ - **Half the sanitizer catalog was unreachable.** 191 of 381 sanitizer entries
588
+ use dotted callees (`Encode.forHtml`, `filepath.Clean`, `validator.isEmail`)
589
+ indexed under the full dotted key, but every lookup path reduced to the
590
+ callee's last segment — so these entries could never be retrieved. Catalog
591
+ lookup now tries the full dotted key before falling back to the last segment,
592
+ the behavior its own header comment already promised.
593
+ - **A sanitizer-blind kill switch.** `builtin-summaries` deleted taint outright
594
+ at ~15 name-matched builtins (`parseInt`, `encodeURIComponent`,
595
+ `DOMPurify.sanitize`...) regardless of threat family, contradicting the
596
+ engine's own documented doctrine that sanitizers demote, never kill —
597
+ `x = encodeURIComponent(t); db.query(x)` silently lost its SQLi finding.
598
+ Now demotes through the same family-scoped sanitizer gate every other path
599
+ uses.
600
+ - **Three bench-shape leaks were opt-out instead of opt-in**, violating this
601
+ repo's own documented convention (`AGENTIC_SECURITY_BENCH_SHAPE=1` to enable,
602
+ never `AGENTIC_SECURITY_BLIND_BENCH` to disable): a Juliet path-prefix
603
+ category filter with no env check at all, and two Java answer-key mechanisms
604
+ gated the wrong direction.
605
+ - **Cross-file import resolution was dead code with zero callers.** `fileContents`
606
+ was never threaded into `buildCallGraph`, so its re-export/import-binding
607
+ resolution never ran — two same-named functions in unrelated files collided
608
+ by bare name on every scan.
609
+ - **The points-to graph was built and then never wired in.** `AGENTIC_SECURITY_POINTS_TO=1`
610
+ computed a real alias graph but `runTaintEngine` never copied it onto
611
+ `callContext`, so the opt-in flag caught nothing. A second bug in
612
+ `aliasesForVar` (stripping only the first `::` instead of the qid's full
613
+ prefix) was fixed alongside it, since the qid itself always contains
614
+ multiple `::` segments.
615
+ - **Guard recognition was flow-insensitive.** `dropGuardedFindings` matched a
616
+ guard-shaped regex anywhere in a −25/+5 line window with zero correlation to
617
+ the sink's actual tainted identifier, killing real SSRF/path findings
618
+ whenever unrelated guard-shaped text sat nearby. Rewritten to require the
619
+ guard match to appear near one of the sink's own argument identifiers.
620
+ Reachability annotation was also fixed to record "unknown" rather than
621
+ "unreachable" for languages with no call-graph data — only a strict `false`
622
+ should demote a finding, and absence of evidence isn't evidence of absence.
623
+ - **Deep mode was unreachable from the MCP and LSP integration surfaces.**
624
+ `scan_diff` and `scanFile` never enabled deep mode, so the interprocedural
625
+ taint engine — the thing most likely to catch a real cross-function
626
+ vulnerability — never ran from either integration.
627
+ - **Deep-mode (IR-TAINT) findings bypassed the entire finding pipeline.** They
628
+ were appended *after* dedup, clustering, stable-ID assignment, family
629
+ backfill, confidence, and calibration had already run once, so a sink caught
630
+ by both the regex layer and deep mode produced two findings — one of them
631
+ permanently unscored (no family, no calibrated confidence, no reachability
632
+ demotion, no mitigation annotation). Deep-mode findings now enter the same
633
+ pre-dedup pool as every other detector's output and ride the identical
634
+ pipeline. Fixing this exposed two further latent bugs it's now safe to state
635
+ plainly: dedup's winner-selection didn't prefer a real interprocedural
636
+ taint-walk finding over a same-severity flat pattern match at the same sink
637
+ (an IR-TAINT finding could lose a tie and take its sanitizer/chain evidence
638
+ down with it), and root-cause clustering keyed its "same sink" bucket on a
639
+ generic catalog rule id rather than a per-line signal, so two unrelated
640
+ `eval()` calls in one file could collapse into a single reported finding the
641
+ moment deep-mode findings started reaching that annotator. Both are fixed.
642
+
643
+ Verification for all nine items: full test gate green, CVE-replay corpus
644
+ 214/214 with zero drift, metamorphic/adversarial mutation gate 6/6, self-scan
645
+ precision gate re-baselined against three fully root-caused (not blindly
646
+ accepted) drifts, and the pre-push gate's per-language taint-recall check
647
+ confirmed no regression. `@vercel/ncc` (dev-only build dependency) was also
648
+ bumped 0.44.1 → 0.45.0 to clear the release gate's dependency-currency check.
649
+
650
+ R3 (route deep-mode findings through the full annotator pipeline) was the last
651
+ item in Theme A. Themes B–E of the same PRD — semantic type/import-aware
652
+ matching, control-flow-blind-parser fixes for Java/C#/Kotlin/PHP, deeper
653
+ interprocedural completeness, and DOM/loop-element flow modeling — remain open
654
+ and are tracked in `docs/DETECTION_GAP_REMEDIATION_PRD.md`.
655
+
656
+ ## 0.136.9 — the real bug: the single-file bundle was never actually self-contained
657
+
658
+ 0.136.8's diagnostic logging answered the question immediately:
659
+
660
+ ```
661
+ Error [ERR_MODULE_NOT_FOUND]: Cannot find module
662
+ '.../scanner/dist/449.index.js' imported from
663
+ '.../scanner/dist/agentic-security.mjs'
664
+ ```
665
+
666
+ `dist/agentic-security.mjs` is documented and committed as a self-contained
667
+ single-file bundle — `.gitignore`'s own comment says the reusable
668
+ `scan.yml` workflow fetches *only that one file* from
669
+ raw.githubusercontent.com so downstream users need no install step. That
670
+ was never quite true. `bin/agentic-security.js` lazily loads each
671
+ subcommand's implementation with `await import('../src/...')` — about 58
672
+ call sites, deliberately, so running `agentic-security scan` doesn't pay
673
+ the cost of loading every posture/discovery/compliance module `agentic-security
674
+ compliance` or `agentic-security hunt` would need. `ncc` code-splits every
675
+ one of those into its own `dist/NNN.index.js` chunk rather than inlining
676
+ it, and the chunk is loaded at runtime via a path relative to the bundle's
677
+ own location on disk — but `.gitignore` only ever allowlisted the main
678
+ `.mjs`, its sha256 sidecar, and the compliance-frameworks data. All 38
679
+ chunk files were silently gitignored. `.claude/settings.local.json`-style
680
+ invisible: they exist on any machine that has ever run `npm run build`
681
+ locally (which is every contributor's, permanently, from the first
682
+ `npm install`), so nobody — human or gate — had a checkout that lacked
683
+ them until this release's hosted CI runs did.
684
+
685
+ This is the third occurrence of the identical bug class in this file's own
686
+ history — `.gitignore` already carries a comment about the same thing
687
+ happening to `compliance-frameworks/` data before ("the shipped CLI
688
+ silently listed ZERO frameworks and exited 0"). Same shape both times: a
689
+ single tracked entry point quietly depends on sibling files nobody
690
+ allowlisted.
691
+
692
+ Fixed by allowlisting `scanner/dist/*.index.js` and committing all 38
693
+ current chunks (744 KB), and by adding a permanent regression guard
694
+ (`test/dist-chunks-tracked.test.js`, wired into `test:lifecycle`) that
695
+ diffs `dist/*.index.js` on disk against `git ls-files dist` and fails
696
+ loud, by name, the moment a future build produces a chunk nobody
697
+ allowlisted — rather than waiting for a lazy code path to hit it in
698
+ production. Verified RED against the pre-fix git state (all 38 chunks
699
+ correctly reported untracked) and GREEN after staging them.
700
+
701
+ ## 0.136.8 — instrumenting a real, still-unexplained hosted-CI-only failure
702
+
703
+ 0.136.7's hosted release run got past every other check and failed on
704
+ exactly one: `test/mttr.test.js`'s CLI-wiring test, which asserts a real
705
+ `agentic-security scan` reports an `mttr` field in `last-scan.json`. That
706
+ field comes from a deliberately best-effort code path in `cmdScan`
707
+ (`bin/agentic-security.js`) wrapped in a bare `catch { /* MTTR is
708
+ best-effort — never block a scan write */ }` — correct as a product
709
+ decision (a scan must never fail because a secondary metric couldn't be
710
+ computed), but it means whatever throws inside that block has never been
711
+ visible to anyone, including this investigation.
712
+
713
+ This is now confirmed NOT the shared-runner flakiness 0.136.5/0.136.6
714
+ blamed it as: reran the identical failed job against the identical commit
715
+ (`gh run rerun --failed`, no code change) and got the exact same failure,
716
+ same file, same line, same message, a fourth consecutive time. Extensive
717
+ local reproduction attempts — simulating `CI=true`/`GITHUB_ACTIONS=true`,
718
+ running the full 1328-test `test:posture` scope concurrently to match CI's
719
+ exact invocation shape, invoking the real bundled CLI directly under the
720
+ same env vars — all passed cleanly. Whatever this is, it is deterministic
721
+ on GitHub's hosted runner and has not reproduced anywhere else tried so far.
722
+
723
+ Rather than keep guessing, the silent catch now logs the actual error to
724
+ stderr whenever `CI`/`GITHUB_ACTIONS` is set (or `AGENTIC_SECURITY_MTTR_DEBUG=1`
725
+ locally), and the test's own assertion message now includes the scan
726
+ subprocess's stderr. Scan behavior is unchanged — this is instrumentation
727
+ only, shipped specifically to get a real answer out of the next hosted run
728
+ instead of another guess.
729
+
730
+ ## 0.136.7 — the doc-drift checker was resolving paths outside the repo
731
+
732
+ 0.136.6's hosted release run failed on a check this same audit added a few
733
+ releases ago: `check-doc-drift.mjs`'s own regression test, "the real
734
+ repository currently has zero mechanically-checkable drift," flagged root
735
+ CLAUDE.md's documented (and correctly gitignored) `.claude/settings.local.json`
736
+ reference as a dangling path.
737
+
738
+ The actual defect was in `resolveCandidate`'s search bases, not in the
739
+ reference: for a CLAUDE.md near the repo root, one of the fallback bases is
740
+ "two directories up from the CLAUDE.md's own directory" — meant for nested
741
+ CLAUDE.md files reaching back toward the repo root, but for the ROOT
742
+ CLAUDE.md itself that lands OUTSIDE the checkout entirely, in whatever
743
+ happens to be the parent of wherever the repo was cloned. On the
744
+ maintainer's laptop that's their home directory, which happens to contain
745
+ an unrelated, machine-global `~/.claude/settings.local.json` from ordinary
746
+ Claude Code usage — so the checker "resolved" the reference against a file
747
+ that has nothing to do with this project, passed locally, and failed on a
748
+ clean CI checkout where no such coincidence exists. `resolveCandidate` now
749
+ rejects any candidate base or resolved path outside the repo root, and
750
+ `settings.local.json` — genuinely optional, gitignored, user-created — joins
751
+ the checker's existing known-example-basename allowlist as a second,
752
+ independent fix.
753
+
754
+ Reproduced without touching CI: moved the local override file aside,
755
+ confirmed the existing regression test still passed (proving the checker was
756
+ resolving against something else entirely), traced it to the home-directory
757
+ escape, fixed both the escape and the allowlist gap, verified clean with the
758
+ file present and absent, then restored it.
759
+
760
+ ## 0.136.6 — correction: the "flaky" dataflow tests were a real, deterministic gap
761
+
762
+ 0.136.5's entry below called a cluster of `test:dataflow` failures on the
763
+ hosted runner resource-contention flakiness, because a second, simultaneous
764
+ workflow run of the identical commit showed a completely different failure
765
+ and neither reproduced locally. That diagnosis was wrong, and the actual
766
+ cause is more interesting: 13 test files pass `runScan(dir, { deep: true })`
767
+ to exercise the interprocedural taint engine directly, but never opt into
768
+ `AGENTIC_SECURITY_DEEP_IN_CI` — and `engine.js` deliberately auto-disables
769
+ deep mode under any detected CI environment unless that second flag is also
770
+ set, precisely so a pathological file can't hang a CI pipeline. Every one of
771
+ those 13 files has therefore been silently CI-broken (never actually
772
+ exercising the deep engine there, only recording the "skipped in CI"
773
+ placeholder finding) since the day it was written — invisible until this
774
+ release's tag push put 62 accumulated commits through hosted CI for the
775
+ first time. Reproduced deterministically with `CI=true node --test
776
+ <file>.test.js` locally (no CI service required), fixed by threading
777
+ `deepInCi: true` alongside `deep: true` at all 21 call sites across those 13
778
+ files, and confirmed both directions: `npm test` is clean with `CI` unset
779
+ and with `CI=true` set.
780
+
781
+ The earlier flakiness diagnosis wasn't entirely wrong — the *other* workflow
782
+ run's single MTTR-wiring failure genuinely didn't reproduce on a third run
783
+ and remains unexplained — but it was wrong about *this* failure cluster,
784
+ which was 100% reproducible once isolated with the right environment
785
+ variable rather than blamed on shared-runner load.
786
+
787
+ ## 0.136.5 — the release workflow gets its own missing dependency
788
+
789
+ 0.136.4 was tagged and its release workflow ran — for the first time ever,
790
+ since the 60-odd commits it carried had accumulated across many local
791
+ sessions without a single hosted-CI push. The gate caught a real gap in
792
+ itself: `nist-catalog-freshness` (a check this same release adds, see below)
793
+ shells out to `scripts/nist-compliance/build-catalog.py`, which needs
794
+ `openpyxl` to read the source workbook. That's present on the maintainer's
795
+ machine via a system Python install, but nothing installs it on the hosted
796
+ runner — so the check could never have passed there. `release.yml` now
797
+ installs it explicitly before the gate runs.
798
+
799
+ The same CI run also showed roughly a dozen dataflow tests fail — a
800
+ completely different set than the *other* workflow (`ci.yml`) failed on the
801
+ same commit at the same time (one flaky MTTR-wiring test, no overlap with the
802
+ dataflow set). Neither set reproduces locally, isolated or otherwise. That
803
+ non-overlap is the signature of resource-contention flakiness on a shared
804
+ runner, not a deterministic regression, so it isn't chased further here — but
805
+ it's worth knowing about if a future release gate flakes on `npm test` again.
806
+
807
+ Per the project's own precedent (see 0.136.1 below): a tag that failed to
808
+ publish stays where it is rather than being moved. 0.136.4 is that tag. This
809
+ ships as 0.136.5 instead, otherwise identical.
810
+
811
+ ## 0.136.4 — dominance-correct implicit-flow taint, and wiring three dead-reachable tools
812
+
813
+ A capability-PRD follow-up audit found several places where a real, tested
814
+ mechanism existed in the code but nothing in the product could actually reach
815
+ it. This release closes those gaps rather than documenting them further.
816
+
817
+ **`implicit-flow.js` now uses real dominance, not a depth counter.** The
818
+ branch-scoping check for implicit taint (does a constant-argument sink sit
819
+ *inside* a tainted branch, or after it closes?) was a path-dependent DFS
820
+ depth-counter, which cannot distinguish a join point from a nested branch and
821
+ both over- and under-attributes depending on CFG shape. It's replaced with a
822
+ proper dominance check (`ssa.js`'s `computeDominators`, already used for φ-node
823
+ placement, now exported and reused here) plus a predecessor-count "sole
824
+ parent" test — needed because an else-less `if`'s CFG lowering links the
825
+ condition directly to the join node, which a naive dominance check alone would
826
+ still misread as a branch root. `dataflow/engine.js`'s implicit-flow post-pass
827
+ is split into two correctly-scoped passes: a sink-call-must-be-inside-the-
828
+ branch check for constant-argument leaks, and an ungated check for
829
+ already-tainted-variable-as-argument leaks, which don't need the sink itself
830
+ to be branch-local.
831
+
832
+ **Three previously-dead mechanisms are now reachable.** `verify_fix`'s PoC-
833
+ recheck leg always reported `not-requested` — the caller never had the PoC to
834
+ pass it, so it's now looked up server-side from the finding's own
835
+ `last-scan.json` entry. `fix-honesty-gate.js`'s deterministic honesty checks
836
+ (vague-assurance residual prose, unbacked false-positive verdicts) were fully
837
+ built and consulted by the verifier, but `fixMeta` was never in `apply_fix` or
838
+ `verify_fix`'s MCP schema, so no caller could ever supply one; a dishonest
839
+ `fixMeta` now blocks the write itself, not just the report. `verifyRunAttestation`
840
+ had no CLI caller at all — `verify-attestation` now auto-detects a
841
+ run-attestation shape and re-scans the target to check it reproduces the
842
+ attested digest, backed by two new release-gate checks
843
+ (`attestation-self-check`, `nist-catalog-freshness`).
844
+
845
+ **Stale docs, fixed instead of flagged.** `docs/compliance/{nist-ai-600-1,
846
+ owasp-asvs}-coverage.md` carried static control tables that drifted from the
847
+ live evaluator; both now point at the `/compliance` walkthrough/report instead
848
+ of duplicating data that can go stale. The ASVS doc also had a genuine
849
+ version mismatch (4.0.3 vs. the 5.0 catalog actually in use). A new
850
+ `scripts/check-doc-drift.mjs` catches this class of staleness mechanically —
851
+ it resolves every backtick-quoted path/export reference in a CLAUDE.md file
852
+ against the real filesystem — after this audit found several by hand.
853
+
854
+ **CVE-replay corpus:** four new capability entries (IaC open-ingress, LLM
855
+ system-prompt injection, MCP untrusted-install, API missing-auth/BFLA),
856
+ closing four of six previously-flagged zero-coverage categories; each verified
857
+ `pre:TP post:TN` against the real runner before joining the baseline. SBOM and
858
+ SCA-reachability are documented in `bench/cve-replay/CONTRIBUTING.md` as
859
+ structurally unable to fit this corpus's binary presence/absence schema —
860
+ they're covered by their own test suites instead.
861
+
862
+ CMP-1's family-alias table also closed three more gaps
863
+ (`k8s-pod-security-privileged`, `mcp-audit.js`'s `agent-tool-exec` backfill,
864
+ dependency-confusion family tagging).
865
+
866
+ **No functional change from 0.136.2.** This version exists for one reason, and
867
+ it is worth stating plainly rather than dressing up: 0.136.2 reached npm from a
868
+ maintainer's laptop, not from CI, so it carries **no provenance attestation** —
869
+ nothing ties that tarball to this repository or this commit beyond trust in the
870
+ publisher.
871
+
872
+ npm will not accept a re-publish of an existing version, so obtaining provenance
873
+ requires a new one. 0.136.3 is that, and nothing else.
874
+
875
+ The release path itself was already proven end to end on 0.136.2: the gate passed
876
+ on a clean runner, npm signed a provenance statement and recorded it in the
877
+ Sigstore transparency log, and only the final registry upload was rejected —
878
+ because the token in CI was not authorized to publish. With a valid automation
879
+ token that last step completes, and the attestation that was already being
880
+ generated actually lands.
881
+
882
+ Verify it yourself once published:
883
+
884
+ ```
885
+ npm view @clear-capabilities/agentic-security-scanner@0.136.3 --json | jq .dist.attestations
886
+ ```
887
+
888
+ `null` means it went out unattested again. `dist.signatures` is NOT the same
889
+ thing — the registry signs every package it serves; provenance is the separate
890
+ Sigstore statement binding the artifact to its source.
891
+
12
892
  ## 0.136.2 — authenticate the gate on the path that actually runs it
13
893
 
14
894
  0.136.1 removed the self-deadlock and the release workflow got further: every