@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -366,6 +366,7 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
366
366
  stableId: finding.stableId || null,
367
367
  ruleId: finding.ruleId || finding.cwe || finding.family || null,
368
368
  vuln: finding.vuln || finding.title || null,
369
+ findingProvenance: finding.findingProvenance || null,
369
370
  });
370
371
  written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath, attemptOrdinal: entry.attemptOrdinal });
371
372
  }
@@ -18,12 +18,16 @@
18
18
  import { spawnSync } from 'node:child_process';
19
19
  import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
+ import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
21
22
  import { runFullScan } from './engine.js';
22
23
 
23
24
  const MAX_FILES_PER_SCAN = 5000;
24
25
 
26
+ // `root` is the scan target's repository, not this project's own trusted
27
+ // checkout — hardened per FR-PROV-024 / the second Finding Provenance PRD
28
+ // audit (same exposure class as provenance/git-evidence.js's `_run`).
25
29
  function _git(root, args) {
26
- const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
30
+ const r = spawnSync('git', hardenGitArgs(args), { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: hardenGitEnv() });
27
31
  return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '' };
28
32
  }
29
33
 
@@ -83,7 +87,10 @@ function _listFilesAtRef(root, ref) {
83
87
  }
84
88
 
85
89
  function _readFileAtRef(root, ref, file) {
86
- const r = _git(root, ['show', `${ref}:${file}`]);
90
+ // `--no-textconv`: this blob-cat form of `show` was verified NOT
91
+ // reachable via a hostile textconv driver in current git — kept for
92
+ // defense-in-depth/uniformity, same as pr-delta.js's equivalent.
93
+ const r = _git(root, ['show', '--no-textconv', `${ref}:${file}`]);
87
94
  if (!r.ok) return null;
88
95
  return r.stdout;
89
96
  }
@@ -95,7 +102,13 @@ async function _scanAtRef(root, ref) {
95
102
  const c = _readFileAtRef(root, ref, f);
96
103
  if (c != null) fileContents[f] = c;
97
104
  }
98
- const scan = await runFullScan({ fileContents, scanRoot: root }, () => {});
105
+ // `provenance:false` this is a HISTORICAL ref, not "the state of this repo
106
+ // right now". Two things must not happen here: resolving git provenance for
107
+ // findings that already are a point in history (pure waste), and letting
108
+ // updateLifecycle see this partial, historical finding set as the current
109
+ // one — it marks every open stableId NOT in the set as `remediated`, so one
110
+ // per-ref scan would mass-remediate the whole project's real open findings.
111
+ const scan = await runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
99
112
  return {
100
113
  ref,
101
114
  fileCount: Object.keys(fileContents).length,
@@ -177,8 +190,12 @@ export async function runWhatIf(root, { overlays = [], remove = [] } = {}) {
177
190
  }
178
191
  }
179
192
  // Baseline (without overlays) for delta computation.
180
- const baseScan = await runFullScan({ fileContents: _baselineFor(fileContents, overlays, remove, root), scanRoot: root }, () => {});
181
- const whatIfScan = await runFullScan({ fileContents, scanRoot: root }, () => {});
193
+ // Both legs are hypothetical snapshots being differenced against each other,
194
+ // never the repo's current state — `provenance:false` for the same reason as
195
+ // _scanAtRef above (no provenance to resolve, and updateLifecycle must not
196
+ // treat either snapshot as "what is open right now").
197
+ const baseScan = await runFullScan({ fileContents: _baselineFor(fileContents, overlays, remove, root), scanRoot: root, provenance: false }, () => {});
198
+ const whatIfScan = await runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
182
199
  const baseIds = new Set((baseScan.findings || []).map(f => f.stableId || f.id));
183
200
  const wIds = new Set((whatIfScan.findings || []).map(f => f.stableId || f.id));
184
201
  const introduced = (whatIfScan.findings || []).filter(f => !baseIds.has(f.stableId || f.id)).map(_compact);
package/src/ir/CLAUDE.md CHANGED
@@ -10,10 +10,11 @@ consumed by `scanner/src/dataflow/` for taint analysis.
10
10
  | JS / TS | `parser-js.js` | `@babel/parser`. **Taint-recall PRD (80%) Tier 3: JSX had ZERO IR modeling** — every `JSXElement` fell through `exprOf`'s switch to `{kind:'unknown'}`, so `return <div dangerouslySetInnerHTML={{__html: html}} />` (React's canonical XSS sink, and a real corpus miss) silently dropped `html`'s taint entirely, even though the plain-JS member-write form (`x.dangerouslySetInnerHTML = {...}`) was already cataloged. Deliberately narrow, not general JSX modeling: `exprOf`'s new `JSXElement` case (via `_findDangerouslySetInnerHTML`) extracts ONLY the `dangerouslySetInnerHTML` attribute's `__html` property value — found on the element itself or, recursively, any descendant (the attribute can sit on a nested element, not just the one directly returned) — and lowers it to a synthetic call (`__jsx_dangerously_set_inner_html__`), targeted by a sink of the same name (`react-jsx-dangerouslySetInnerHTML`) sibling to the existing member-write entry. Ordinary JSX children (`<div>{unsafeText}</div>`) are deliberately NOT modeled — React auto-escapes children by default, so that shape isn't itself a vulnerability the way `dangerouslySetInnerHTML` is. Guarded by `test/catalog-xss-p4.test.js`. |
11
11
  | Python | `parser-py-cst.js` | Python 3.8+ stdlib `ast` via subprocess (default when available) |
12
12
  | Python | `parser-py.js` | Hand-rolled regex parser (fallback when python3 missing) |
13
+ | (tool capability probe) | `chrome-probe.mjs` | Chrome/Chromium binary discovery — optional local tool, detects and probes availability via `probeChromeAvailable()`, following `parser-py-cst.js`'s established graceful-degradation pattern. Consumed by Milestone 4's image export functions (`exportPng`/`exportPdf`/`exportSvg` in `scripts/export-image.mjs`) for headless rendering. |
13
14
  | Java | `parser-java.js` | `java-parser` npm package (**async only** — the deep path in `engine.js` therefore awaits `buildProjectIRAsync` when any `.java` file is present, and uses the sync builder otherwise).
14
15
  ⚠ Three defects made Java taint impossible until v0.136.3+: the sync-only call site; a CST walk looking for `blockStatement` on a `block` (java-parser nests `block → blockStatements → blockStatement`), which emptied every method CFG; and `exprFromCst` missing the `primary → primaryPrefix + primarySuffix` form that models **every** method call. Guarded by `test/java-taint-flow.test.js`. Real parameter names (previously always `params: []`, marked "deferred") plus Spring `@RequestParam`/`@PathVariable`/`@RequestBody`/`@RequestHeader` param annotations (`fn.paramAnnotations`) are extracted in one CST walk over `formalParameterList` (PRD R14(a) Task 5). Varargs parameters (`String... args`) live under a distinct `variableArityParameter` node this walk doesn't extract from — they're gracefully dropped, not corrupted or crashed on. **PRD R9 (partial): `fn.calls` is now populated** via the shared `call-sites.js#callSitesFromCfg` (the same language-agnostic helper `parser-py-cst.js` uses) — Java's CFG nodes (`call`, `assign`, `return`, `if`) already matched the documented contract. This creates real cross-file call-graph edges for Java (`callgraph.js`'s `edges`/`callersOf`/`resolveKnownCallee`, previously always empty for Java) — it does NOT change the generic tainted-call-argument fallback in `engine.js`'s `exprTaint`, which reads CFG expression args directly off `expr.args` and already worked for Java independent of `fn.calls`. However, **same-class (intra-class) method calls do not resolve, in both the bare and `this.`-qualified forms**: `parser-java.js` names functions `"App.buildCmd"` (class-qualified), but a bare call extracts `"buildCmd"` (unqualified) and `callgraph.js`'s name-based resolution cannot match them — this is the most idiomatic Java call shape (private helpers, intra-class delegation) and remains a real gap, documented here as a candidate follow-up PRD item (a per-file bare-tail fallback in `callgraph.js`, mirroring the existing `~bare~`-key collision-refusal pattern, would plausibly fix it without touching `parser-java.js`). `this.buildCmd(id)` is not merely unresolved, it's worse: `parser-java.js:76` lowers any `this.`-qualified call whose prefix isn't a plain FQN to the literal callee string `"unknown"`, so it doesn't fail closed, it fails to a fabricated name. Guarded by `test/parser-java-calls.test.js` (the test's honest caveat discloses this; the fixture shows that edges exist but unresolved) (mirrors `test/parser-rb-calls.test.js`, the identical `fn.calls` wiring, though Ruby's unqualified names make bare-call resolution work there). **PRD R8: `walkStmts` now recurses into `for`/`try`/`switch`/`do`/bare-block bodies** (previously only `if`/`while` were walked — every other braced statement kind silently dropped its body from the CFG, including try-with-resources, the single most idiomatic JDBC shape). A fix round closed two further gaps the initial review found: enhanced-for (`for (x : xs)`) now synthesizes an assign binding the loop variable to the iterated expression, so the variable itself carries taint provenance (mirroring `parser-js.js`'s `ForOfStatement` pattern); and Java 14+ arrow-form `switch` (`case 1 -> …`) is now recognized via a second CST-shape branch. Deferred, not fixed: `forInit`/`forUpdate` clauses of a basic 3-clause `for` loop are still not walked (only the loop body is); `synchronized` blocks and labeled statements are still fully dropped. Guarded by `test/parser-java-control-flow.test.js`. **Measured `bench/layer-recall` impact: unchanged, 1/25 before and after** — the fix is real and directly proven by the dedicated unit tests above, but this corpus's existing 25 Java fixtures happen not to place a sink genuinely inside a braced control-flow body (the ones with `if`/`try` syntax use it as a single-line guard clause ahead of a flat-level sink, not a nested one) — see the PRD R8 status entry for the full explanation and the same finding for C#. **Taint-recall PRD (80%) Tier 4: chained-call CST fix.** `exprFromCst`'s `primaryPrefix` handling previously used `.find(Boolean)` to grab the FIRST `methodInvocationSuffix` in a `primary` node's `primarySuffix[]` array and discarded the rest — so any 2+-level fluent chain (`DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xml)`, `new SpelExpressionParser().parseExpression(expr).getValue()`) resolved to the wrong (inner, usually untainted) call, and the real sink call at the end of the chain was invisible to the taint walker. Rewritten to walk the full `primarySuffix[]` array in source order, dot-joining bare member-access segments (`{Dot, Identifier}` suffixes) into the callee name and accumulating args from every `methodInvocationSuffix` encountered — same outermost-first accumulation convention (`args.concat(soFar)`) as the `_followChain` helper shared by the other five hand-rolled parsers (this PRD's earlier, language-general fix for the identical class of bug: a trailing call/member continuation being silently dropped rather than followed). A companion catalog entry (`java-spel-getvalue`, receiver-scoped to `^parseExpression`, `argIndex: 'all'`) was added because the chain fix shifts the terminal callee segment from `parseExpression` to `getValue` — same "terminal segment shift" pattern documented for Kotlin/Go/Java elsewhere in this PRD. Fixes `CVE-2018-1000632-java-xxe` (DocumentBuilderFactory chain) and `CVE-2018-1273-java-code-injection` (SpelExpressionParser chain). Guarded by two new cases in `test/java-taint-flow.test.js`. **UPDATE (Tier 3 command-injection audit): the `_SHELL_META` gap this note originally flagged is fixed.** `CVE-2016-10033-java-cmdi`'s real-world shape `Runtime.getRuntime().exec("ping " + host)` was blocked by a SEPARATE, language-agnostic core-engine bug (`engine.js`'s `literalSkeletonMatchesFamily`/`_SHELL_META`, requiring a shell metacharacter in the STATIC portion of a CWE-78 concat — backwards for command injection, see `dataflow/CLAUDE.md`), now removed entirely. The corpus fixture also needed a Tier 2 fixture enrichment (`@RequestParam` on the `host` parameter — the committed fixture had no cataloged source at all, a second, independent blocker this note's original git-stash bisection had isolated away from before concluding `_SHELL_META` was the (sole, at the time) remaining issue). Both fixed together; `CVE-2016-10033-java-cmdi` now scores `pre:TP post:TN`. **Taint-recall PRD (80%) Tier 5: a THIRD, independent chain-CST bug — a constructor STARTING a chain.** Found via `CVE-2019-3799-spring-ssrf-shape`'s real-world shape `new URL(url).openStream()`: the chained-call fix above (the `primarySuffix[]` walk) only handles a chain that starts from an FQN/identifier prefix (`prefix?.children?.fqnOrRefType?.[0]`) — a chain whose FIRST segment is itself `new X(args)` takes a completely different CST shape and was never checked, so both the class name and the constructor's own args were silently dropped, leaving only the trailing `.openStream()` call with no receiver. Root-caused via temporary `console.error(Object.keys(prefix?.children))` tracing (the first guess, matching the STANDALONE `new X(args)` handling elsewhere in this file at `unqualifiedClassInstanceCreationExpression` directly off `node`, was wrong — a chain-starting constructor sits one level deeper, under `prefix?.children?.newExpression?.[0]?.children?.unqualifiedClassInstanceCreationExpression?.[0]`). Fixed by extending the same `primaryPrefix` chain-seed logic to also recognize this shape (`ctorPrefix`), extracting the class name and constructor args exactly like the FQN case, so the chain fix now covers both "chain starts on an identifier" and "chain starts on a constructor". Companion catalog fix: `java-url-openstream` was previously keyed to the (now-unreachable-via-chain) bare `callee: 'URL'`; corrected to `callee: 'openStream', receiver: '^URL'` — the same "terminal segment shift" pattern this PRD documents repeatedly elsewhere (Kotlin/Go). Guarded by `test/catalog-path-ssrf-p3.test.js`. |
15
16
  | Go | `parser-go.js` | Hand-rolled. **Taint-recall PRD (80%) Tier 4: inline anonymous closures.** Go's dominant HTTP-handler-registration idiom across every framework this file targets (net/http, gin, echo, fiber, chi) is an inline closure passed as the LAST argument to a registration call — `app.Get(path, func(c *fiber.Ctx) error { ... })`, `http.HandleFunc(path, func(w, r) { ... })`. This parser previously had ZERO support for anonymous functions at all: `FUNC_RE` requires a NAME between `func` and `(`, so an inline closure never matched it, and `_lowerExpr`'s generic call-matching regex mis-parsed `func(params) rtype { body }` as a call to something literally named "func" — `matchBalancedCall` correctly captured only `(params)` as that "call"'s own args, and the return-type token plus the ENTIRE closure body were silently discarded (no `.method(...)` continuation follows a return type, so `_followChain` found nothing to recover). This was very likely this PRD's single most consequential Go gap — it made every framework's route-handler BODY invisible to taint analysis regardless of what it did — found via this PRD's Tier 3 command-injection audit, not the parent PRD's original per-language sweep. Fixed by `_extractTrailingClosureArg` detecting a statement's LAST top-level call argument as an inline closure literal and INLINING its body directly into the enclosing function's CFG right after the registration call — a permissive, recall-preserving approximation (the closure genuinely runs later, but what matters for taint is that its statements become real CFG nodes at all), mirroring the Kotlin trailing-lambda / Ruby trailing-block precedent elsewhere in this PRD. The closure's OWN parameter (`c`, `w`, `r`) needs no synthetic taint binding, unlike Kotlin's `.forEach { x -> ... }`: a framework context object isn't itself a source — `c.Query(...)` is recognized by the EXISTING member/call-source catalog matching regardless of which function scope `c` was declared in. Chained calls before the closure arg (`app.Group("/api").Get(path, func(){...})`) are not specially handled — out of scope, matches this file's "handle the dominant shape" precedent elsewhere. Guarded by `test/parser-go.test.js`. Two catalog/engine precision bugs surfaced in the SAME audit, both fixed alongside: `go-os-exec-command`'s `argIndex: 0` checked the WRONG argument (the real dangerous shape is `exec.Command("/bin/sh", "-c", tainted)`, where arg 0 is always the literal interpreter and the tainted content sits later) — widened to `argIndex: 'all'`, gated by a new `match.requireLiteralArg` precision primitive (`catalog.js`/`engine.js`) requiring arg 0 to literally be a shell interpreter, so the safe array-execve form (`exec.Command("ping", "-c", "1", host)`) does not spuriously fire; and a "terminal segment shift" (same pattern as `kt-xpath-evaluate`/`java-spel-getvalue`/`go-r-uquery-get` elsewhere in this PRD) for `exec.Command(...).Output()`/`.Run()`/`.CombinedOutput()`/`.Start()` — chaining a Cmd-execution method directly onto `exec.Command(...)`, the dominant idiom for actually RUNNING the command, collapses the chain into one dotted string whose last segment is no longer "Command". Guarded by `test/catalog-command-injection-p4.test.js`. **Measured `bench/layer-recall` impact: real movement — command-injection (this corpus's dominant Go family) moved from 5/23 → 18/23 across this fix plus the sibling `literalSkeletonMatchesFamily`/CWE-78 engine fix (`dataflow/CLAUDE.md`).** |
16
- | Ruby | `parser-rb.js` | Hand-rolled. **`DEF_RE` must not let `\s*` cross a newline** — it did, and the body slice then started after the method's first statement, silently dropping it from every method (a one-statement body became empty). Guarded by `test/parser-php-rb.test.js`. ⚠ Also emitted no `fn.calls` at all (every OTHER parser does) — `callgraph.js`'s edges/callersOf/resolveKnownCallee are built entirely from `fn.calls`, so this left dead-code demotion and any interprocedural signal that depends on real call-graph resolution (rather than engine.js's generic tainted-call-argument fallback) permanently blind to Ruby. Fixed by deriving `fn.calls` from the CFG via the shared `call-sites.js#callSitesFromCfg` (the same helper `parser-py-cst.js` uses) — Ruby's node shapes already matched its documented contract. Guarded by `test/parser-rb-calls.test.js`. **Taint-recall PRD (80%): full CFG rebuild.** `_buildCfg` previously only recursed into `if`/`unless`/`while`/`until` bodies — `for`, `case`/`when`/`else`, `begin`/`rescue`/`ensure`, and any trailing block attached to a call (`xs.each do |x| … end`, the dominant Rails/ActiveRecord idiom) were silently dropped entirely, with no CFG node at all. `case`/`when` and `begin`/`rescue`/`ensure` use a recall-preserving model (every arm/clause reachable directly from the construct's own entry point, not modeled as mutually exclusive — same tradeoff `parser-kt.js`'s `_buildWhenArms` already established). A `do` block's opener also had to sit at the START of a line to be recognized at all — `_splitStatements`' depth-tracking now scans the WHOLE line (via `_rbLineDepthDelta`) rather than gating on `_RB_OPENERS.test(line)`, so a trailing `do` (mid-line, the common case) is now correctly consolidated instead of splitting its own body into independent nonsense statements. Trailing blocks recurse unconditionally and bind every named block parameter to the call's receiver (permissive by design — Ruby has no equivalent of Kotlin's implicit-this `apply`/`run` that would need to NOT bind). A recursion-depth guard (60) was added, matching every other R8-style rebuild in this codebase. Three further, independent bugs surfaced during the rebuild's own corpus-fixture debugging, all fixed: (1) `::` (Ruby's module-scope call operator, e.g. `Nokogiri::XML(x)`) was entirely invisible to `matchBalancedCall`'s `[\w.]+` callee regex — normalized to `.` on lowering; (2) a subscript-assignment on a member chain (`response.headers[key] = value`) had no assign-target branch at all and was silently dropped — lowered as a synthetic `<receiver>.[]=(key, value)` call, same shape as `parser-py.helper.py`'s `__setitem__` synthesis; (3) `_lowerExpr` checked "does this start with a quote" BEFORE checking for top-level `+` concatenation, so `"/var/data/" + name` was swallowed whole as one opaque literal — reordered, with a new `_splitTopLevelPlus` helper (string/paren/bracket-aware, so `"a+b"` is not mis-split) fixing it the same way `parser-go.js`'s R3 fix did for the identical ordering bug. Also inherits this PRD's shared chained-call fix (`_followChain`, `matchBalancedCall`-based) and keyword-argument fix from earlier in the same PRD. **Known, deliberately deferred gap:** the brace form of a trailing block (`Nokogiri::XML(xml) { |c| … }`) has its TRIGGER call correctly recognized (unlike before, where the trailing `{ }` corrupted or blocked the match), but the block BODY itself is not recursed into — only the `do...end` form got full body recursion, since it is the dominant multi-line Rails idiom and the single corpus fixture needing brace-form support only needed the trigger call's own argument, not its body. **Also known, NOT Ruby-specific:** a sink nested inside ANOTHER call's own argument (e.g. `render plain: URI.open(tainted).read`) is not independently checkable — `engine.js`'s sink-matching operates at CFG-node granularity (the outer `render` call), not on arbitrary nested sub-expressions; the SAME limitation was found via C#/Go/Kotlin corpus work earlier in this PRD, not something this task introduced or fixed. Guarded by `test/parser-rb-control-flow.test.js`. Two genuine ReDoS regexes were caught and fixed by `bench:self-scan:check` during this task — the trailing-block detector's first version had the `(.+?)\s+do` shape (an unbounded lazy prefix, NOT the "optional group between `\s*`" class every other ReDoS fix in this codebase has been; splitting into alternatives alone did not fix it — the leading capturing group itself had to go, replaced with a direct anchored search for `\bdo\b`), and the SAME detector's heuristic still flagged the replacement's residual "optional group between `\s*`" shape even though it measured linear on its own — split into two alternatives to satisfy the detector too. Measured `bench/layer-recall` impact on dataflow-shaped-subset taint recall: real, substantial movement (see `docs/TAINT_RECALL_80PCT_PRD.md`) — Ruby's own family entries moved from mostly-0% to 8/10 taint-detected. **Taint-recall PRD (80%) Tier 3: backtick shell-execution operator.** Found in the command-injection audit: `` `finger #{user}` `` (Kernel#`, equivalent to `%x{...}`) is a completely distinct syntax from a double-quoted string, but had NO recognizer at all — it fell through every `_lowerExpr` branch to `{kind:'unknown'}`, silently dropping the shell command (and any interpolated taint inside it) entirely. Lowered to a synthetic call (`__ruby_backtick_exec__`, an identifier real Ruby code can never actually name a dotted method) carrying the interpolated command as its sole argument, so a normal callee-keyed catalog sink (`rb-backtick-exec`) targets it exactly like any other call-shaped sink. A SEPARATE gap surfaced testing this: a bare backtick expression as its OWN statement (not assigned to a variable — the LAST expression of a `do...end` block, which Ruby implicitly returns, is exactly this shape) matched none of `_lowerStmt`'s branches and was dropped even after the `_lowerExpr` fix; `_lowerStmt` now delegates to `_lowerExpr` directly for this shape. Guarded by `test/catalog-command-injection-p4.test.js`. |
17
+ | Ruby | `parser-rb.js` | Hand-rolled. **`DEF_RE` must not let `\s*` cross a newline** — it did, and the body slice then started after the method's first statement, silently dropping it from every method (a one-statement body became empty). Guarded by `test/parser-php-rb.test.js`. ⚠ Also emitted no `fn.calls` at all (every OTHER parser does) — `callgraph.js`'s edges/callersOf/resolveKnownCallee are built entirely from `fn.calls`, so this left dead-code demotion and any interprocedural signal that depends on real call-graph resolution (rather than engine.js's generic tainted-call-argument fallback) permanently blind to Ruby. Fixed by deriving `fn.calls` from the CFG via the shared `call-sites.js#callSitesFromCfg` (the same helper `parser-py-cst.js` uses) — Ruby's node shapes already matched its documented contract. Guarded by `test/parser-rb-calls.test.js`. **Taint-recall PRD (80%): full CFG rebuild.** `_buildCfg` previously only recursed into `if`/`unless`/`while`/`until` bodies — `for`, `case`/`when`/`else`, `begin`/`rescue`/`ensure`, and any trailing block attached to a call (`xs.each do |x| … end`, the dominant Rails/ActiveRecord idiom) were silently dropped entirely, with no CFG node at all. `case`/`when` and `begin`/`rescue`/`ensure` use a recall-preserving model (every arm/clause reachable directly from the construct's own entry point, not modeled as mutually exclusive — same tradeoff `parser-kt.js`'s `_buildWhenArms` already established). A `do` block's opener also had to sit at the START of a line to be recognized at all — `_splitStatements`' depth-tracking now scans the WHOLE line (via `_rbLineDepthDelta`) rather than gating on `_RB_OPENERS.test(line)`, so a trailing `do` (mid-line, the common case) is now correctly consolidated instead of splitting its own body into independent nonsense statements. Trailing blocks recurse unconditionally and bind every named block parameter to the call's receiver (permissive by design — Ruby has no equivalent of Kotlin's implicit-this `apply`/`run` that would need to NOT bind). A recursion-depth guard (60) was added, matching every other R8-style rebuild in this codebase. Three further, independent bugs surfaced during the rebuild's own corpus-fixture debugging, all fixed: (1) `::` (Ruby's module-scope call operator, e.g. `Nokogiri::XML(x)`) was entirely invisible to `matchBalancedCall`'s `[\w.]+` callee regex — normalized to `.` on lowering; (2) a subscript-assignment on a member chain (`response.headers[key] = value`) had no assign-target branch at all and was silently dropped — lowered as a synthetic `<receiver>.[]=(key, value)` call, same shape as `parser-py.helper.py`'s `__setitem__` synthesis; (3) `_lowerExpr` checked "does this start with a quote" BEFORE checking for top-level `+` concatenation, so `"/var/data/" + name` was swallowed whole as one opaque literal — reordered, with a new `_splitTopLevelPlus` helper (string/paren/bracket-aware, so `"a+b"` is not mis-split) fixing it the same way `parser-go.js`'s R3 fix did for the identical ordering bug. Also inherits this PRD's shared chained-call fix (`_followChain`, `matchBalancedCall`-based) and keyword-argument fix from earlier in the same PRD. **Known, deliberately deferred gap:** the brace form of a trailing block (`Nokogiri::XML(xml) { |c| … }`) has its TRIGGER call correctly recognized (unlike before, where the trailing `{ }` corrupted or blocked the match), but the block BODY itself is not recursed into — only the `do...end` form got full body recursion, since it is the dominant multi-line Rails idiom and the single corpus fixture needing brace-form support only needed the trigger call's own argument, not its body. **Also known, NOT Ruby-specific:** a sink nested inside ANOTHER call's own argument (e.g. `render plain: URI.open(tainted).read`) is not independently checkable — `engine.js`'s sink-matching operates at CFG-node granularity (the outer `render` call), not on arbitrary nested sub-expressions; the SAME limitation was found via C#/Go/Kotlin corpus work earlier in this PRD, not something this task introduced or fixed. Guarded by `test/parser-rb-control-flow.test.js`. Two genuine ReDoS regexes were caught and fixed by `bench:self-scan:check` during this task — the trailing-block detector's first version had the `(.+?)\s+do` shape (an unbounded lazy prefix, NOT the "optional group between `\s*`" class every other ReDoS fix in this codebase has been; splitting into alternatives alone did not fix it — the leading capturing group itself had to go, replaced with a direct anchored search for `\bdo\b`), and the SAME detector's heuristic still flagged the replacement's residual "optional group between `\s*`" shape even though it measured linear on its own — split into two alternatives to satisfy the detector too. Measured `bench/layer-recall` impact on dataflow-shaped-subset taint recall: real, substantial movement — Ruby's own family entries moved from mostly-0% to 8/10 taint-detected. **Taint-recall PRD (80%) Tier 3: backtick shell-execution operator.** Found in the command-injection audit: `` `finger #{user}` `` (Kernel#`, equivalent to `%x{...}`) is a completely distinct syntax from a double-quoted string, but had NO recognizer at all — it fell through every `_lowerExpr` branch to `{kind:'unknown'}`, silently dropping the shell command (and any interpolated taint inside it) entirely. Lowered to a synthetic call (`__ruby_backtick_exec__`, an identifier real Ruby code can never actually name a dotted method) carrying the interpolated command as its sole argument, so a normal callee-keyed catalog sink (`rb-backtick-exec`) targets it exactly like any other call-shaped sink. A SEPARATE gap surfaced testing this: a bare backtick expression as its OWN statement (not assigned to a variable — the LAST expression of a `do...end` block, which Ruby implicitly returns, is exactly this shape) matched none of `_lowerStmt`'s branches and was dropped even after the `_lowerExpr` fix; `_lowerStmt` now delegates to `_lowerExpr` directly for this shape. Guarded by `test/catalog-command-injection-p4.test.js`. |
17
18
  | PHP | `parser-php.js` | Regex-based, hand-rolled. ⚠ **PRD R8 was this codebase's hardest single task — a genuine 3-fix-round debugging saga, all substantially about line-number precision, not detection shape.** The core fix flushes the statement splitter on a closing `}` (previously only on `;`), and adds `try`/`switch` recognizers plus a recursion guard, so statements inside `if`/`while`/`foreach`/`try`/`switch` bodies are now real CFG nodes instead of being dropped or folded into a bogus call node — this alone also resolved a pre-existing bug where `if`/`while`/`foreach` bodies were already being mis-split even before R8 touched them. Round 1 fixed the naive `}`-flush breaking `if`/`else` and multi-clause `try` (via a continuation-keyword lookahead) and switch/case's first-statement drop (via a `:`-based flush, careful to exclude `::` so PHP 8.1 enum cases and `case Foo::BAR:` class-constant labels aren't false-positived) — but round 1's own line-tracking approach was then found wrong for comment-bearing bodies and multi-line headers, a regression the round itself introduced. Round 2 fixed that (comment-skip handlers were discarding newlines uncounted; use exact `_countNewlines`-based computation everywhere) but its own re-review found the overall "exact line" property still failed, due to a *different*, genuinely pre-existing bug in the function-body's own base-line computation (wrong for Allman-brace style, multi-line signatures, blank-line-preceded functions) plus a genuine new regression from round 1 (a comment between `}` and `else`/`catch`/`finally` dropped that continuation's body entirely). Round 3 fixed both and was confirmed clean by an 8-shape holistic sweep. Also fixed along the way: dead `finally` support (greedy regex capture bug), unrecognized `try{}finally{}` with no `catch`, and the `::` case-label false-positive — via a hand-rolled balanced-brace scanner replacing the fragile regex approach. **Known, deliberately deferred gaps** (full list + grouping in the PRD R8 status entry): the PHP 8 `match` expression is unmodeled (same class as Java's arrow-switch); `if`/`else`'s pre-existing greedy-capture bug still drops the else-body's first statement; a heredoc containing a bare `}` loses its sink entirely (a real regression from R8's original commit, not the fix rounds); `elseif`/`else if` chains are still fully unsupported (pre-existing). Guarded by `test/parser-php-control-flow.test.js`. **Measured `bench/layer-recall` impact: unchanged, 1/23 before and after — the corpus's own `1/23 → 2/23` movement is real but belongs to R14(b) (PHP `<module>` top-level lowering), not this task**, confirmed by commit-swap A/B testing (the pre-R8 parser still reproduces 2/23; the pre-R14(b) parser reproduces only 1/23). None of this corpus's `pre/` PHP fixtures place a sink genuinely inside a braced control-flow body — same explanation as the other three languages' rows.
18
19
 
19
20
  **Taint-recall PRD (80%) Tier 4: comment-unawareness + `"literal" . $var` concat mis-parse.** Two PREVIOUSLY-DEFERRED gaps from the R8 list above, closed together (found while working the same gap class for Kotlin, below): (1) `_extractBody` (the function-BODY brace-matcher, a SEPARATE code path from `_splitStatements`'s own comment handling) had ZERO comment awareness — an apostrophe inside ANY comment ("don't", "it's") toggled its string-tracking state exactly like a real string literal, corrupting brace-depth for everything after it; depending on what followed, this either made `_extractBody` return `null` (silently dropping the **entire file's** IR — a single failed top-level function match corrupts every span downstream) or extracted the wrong body. Fixed by making `_extractBody` skip all three PHP comment forms (`//`, `#`, `/* */`) — the same fix `_splitStatements` already had for two of the three; `#`-comments (PHP's third form, previously invisible to `_splitStatements` too) are now handled there as well, careful to exclude `#[...]` PHP 8 attribute syntax from being mistaken for a comment. (2) `"literal" . $var` — arguably the single most common real-world PHP SQL-injection shape — was swallowed whole into one opaque `literal` node by TWO stacked bugs in `_lowerExpr`, each the same "unanchored prefix" defect class: the double-quoted-interpolation branch (`/^"/.test(s) && s.includes('$')`) matched on the WHOLE concat expression merely because it started with `"` and contained a `$` *anywhere* (in `$var`, outside the string, after the `.`), slicing off the wrong first/last characters as if the entire expression were one interpolated string; once that was fixed, the plain string-literal fallback (`/^"/.test(s)`) did the identical unanchored check one layer down and caught what the first fix now let through. Both are now anchored (`/^"(?:[^"\\]|\\.)*"$/`, full match required) so a concat correctly falls through to the `.`-splitting branch instead of either misreading the trailing ` . $var` as string content or swallowing the whole expression as an opaque literal. Guarded by 6 new cases in `test/parser-php-control-flow.test.js`, including an end-to-end `runScan` test proving real `$_GET`-to-PDO-sink taint flow through this exact concat shape. **Measured `bench/layer-recall` impact: real movement, PHP taint recall 2/23 (9%) → 7/23 (30%)** — the largest single per-language jump measured in this PRD, consistent with how common the `"literal" . $var` idiom is in real PHP code. **Taint-recall PRD (80%) Tier 3: PHP carried zero XSS sink entries.** `echo`/`print` are PHP LANGUAGE CONSTRUCTS, not function calls (`echo "<div>" . $_GET['q'] . "</div>";` has no `(` immediately after the keyword), so the statement-form call regex never matched them — the entire echoed expression, including any reflected taint, was silently dropped. `_lowerStmt` now recognizes `echo`/`print` directly and lowers to a synthetic call (`__php_echo__`, args split on top-level commas since `echo` accepts multiple comma-separated expressions), targeted by a new `php-echo-xss` sink — same synthetic-callee convention as Ruby's `__ruby_backtick_exec__` below. Guarded by `test/catalog-xss-p4.test.js`. |
@@ -0,0 +1,150 @@
1
+ // chrome-probe.mjs — Milestone 4, sub-project PNG/SVG/PDF export.
2
+ //
3
+ // Chrome/Chromium binary discovery, mirroring parser-py-cst.js's own
4
+ // already-proven probePythonAvailable() pattern exactly — this
5
+ // codebase's established convention for "optional local tool, detect
6
+ // and degrade gracefully" (see that file's own header comment for the
7
+ // full rationale this file inherits without repeating).
8
+
9
+ import * as cp from 'node:child_process';
10
+ import * as fs from 'node:fs';
11
+
12
+ let _capability = null;
13
+
14
+ // A malformed env var (e.g. AGENTIC_SECURITY_CHROME_PROBE_TIMEOUT_MS=oops)
15
+ // must fall back to the default, never reach spawnSync as NaN — found by
16
+ // the final whole-branch review: `Number('oops')` is NaN, and NaN as a
17
+ // spawnSync `timeout` option throws ERR_OUT_OF_RANGE synchronously.
18
+ // _tryBinary's own try/catch swallows that throw and returns null for
19
+ // EVERY candidate, so probeChromeAvailable would misreport
20
+ // `no-chrome-found` on a machine with a perfectly working Chrome install.
21
+ //
22
+ // Blank/whitespace-only is treated as unset (falls back), not as `0` —
23
+ // found by this fix's own scoped re-review: `Number('')` is `0`, and a
24
+ // naive `n >= 0` guard let an empty-but-exported env var (a blanked
25
+ // .env line, an unset-but-exported CI var) through as a real `timeout:
26
+ // 0`, which spawnSync treats as NO timeout at all — an unbounded hang
27
+ // risk inside this exact capability probe, the failure class
28
+ // parser-py-cst.js's own probePythonAvailable() is already careful
29
+ // about. An explicit `"0"` is still honored (matches this file's
30
+ // pre-fix behavior for that literal value).
31
+ //
32
+ // Must be a safe INTEGER, not merely finite — found by a second scoped
33
+ // re-review, reproduced live: `Number.isFinite(1.5)` is true, so a
34
+ // fractional env var (e.g. "1.5") passed this guard unchanged and then
35
+ // hit spawnSync's own `timeout` option, which throws ERR_OUT_OF_RANGE
36
+ // for any non-integer — the exact class of uncaught throw this whole
37
+ // function exists to prevent, just for a different malformed input
38
+ // than the original NaN case. `-0` is rejected too (Object.is check):
39
+ // it is a safe integer and `-0 >= 0`, so without the explicit check it
40
+ // would silently mean "no timeout", one character away from the "0"
41
+ // this function deliberately allows.
42
+ // Exported test-only — the outcome-based integration tests (a real
43
+ // probe/export succeeding or not) can't distinguish `timeout: 0` from
44
+ // `timeout: <default>` when the underlying command finishes quickly
45
+ // either way, so a direct table of this function's own input/output
46
+ // pairs is the only thing that actually pins the "0"-vs-default
47
+ // boundary — found by a third scoped re-review: the blank-env-var
48
+ // integration test below passed even with the round-2 blank-handling
49
+ // bug fully reintroduced, for exactly this reason.
50
+ export function _validTimeoutMs(raw, fallback) {
51
+ const s = String(raw ?? '').trim();
52
+ if (!s) return fallback;
53
+ const n = Number(s);
54
+ if (!Number.isSafeInteger(n) || n < 0 || Object.is(n, -0)) return fallback;
55
+ return n;
56
+ }
57
+ const PROBE_TIMEOUT_MS = _validTimeoutMs(process.env.AGENTIC_SECURITY_CHROME_PROBE_TIMEOUT_MS, 5000);
58
+
59
+ // Real, common install locations, per platform. Deliberately excludes
60
+ // AGENTIC_SECURITY_CHROME_PATH — that override is handled separately in
61
+ // probeChromeAvailable() as an authoritative, non-fallback choice (see
62
+ // the comment there for why).
63
+ //
64
+ // KNOWN-GOOD ABSOLUTE PATHS ARE TRIED FIRST, PATH-resolvable bare names
65
+ // LAST — found by the final whole-branch review, reproduced live: with
66
+ // bare names tried first, a `chrome` shell script anywhere earlier on
67
+ // PATH than the real browser is silently preferred and then executed
68
+ // with this process's own render arguments. The `--version` output
69
+ // check (`_tryBinary` below) is a functional smoke test, not a security
70
+ // gate — it cannot distinguish a real Chrome/Chromium binary from any
71
+ // script that echoes a matching string. Preferring the well-known
72
+ // install locations narrows, though does not eliminate, this exposure
73
+ // (a compromised PATH entry named exactly `chrome`/`google-chrome`/etc.
74
+ // is still tried if no absolute-path candidate exists on this machine —
75
+ // this feature already assumes local machine trust, same as every other
76
+ // "optional local tool" this codebase shells out to). The `linux` list
77
+ // below was empty in this fix's first pass — found by the scoped
78
+ // re-review to make the whole reordering a no-op on the platform CI
79
+ // images actually run on, since every Linux candidate was a bare PATH
80
+ // name. Package-manager and snap install locations are now included.
81
+ function _candidatePaths() {
82
+ const onPath = ['google-chrome-stable', 'google-chrome', 'chromium-browser', 'chromium', 'chrome'];
83
+ const platformPaths = {
84
+ darwin: [
85
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
86
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
87
+ `${process.env.HOME || ''}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
88
+ ],
89
+ win32: [
90
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
91
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
92
+ `${process.env.LOCALAPPDATA || ''}\\Google\\Chrome\\Application\\chrome.exe`,
93
+ ],
94
+ linux: [
95
+ '/usr/bin/google-chrome-stable',
96
+ '/usr/bin/google-chrome',
97
+ '/usr/bin/chromium-browser',
98
+ '/usr/bin/chromium',
99
+ '/opt/google/chrome/chrome',
100
+ '/snap/bin/chromium',
101
+ ],
102
+ };
103
+ const platformSpecific = platformPaths[process.platform] || [];
104
+ return [...platformSpecific, ...onPath];
105
+ }
106
+
107
+ // Tries one candidate binary. Returns {ok:true, chrome:bin} on a real,
108
+ // working Chrome/Chromium, or null on any failure (missing binary, spawn
109
+ // error, non-zero exit, unrecognized --version output) — never throws.
110
+ function _tryBinary(bin) {
111
+ if (!bin) return null;
112
+ // An absolute-path candidate that doesn't exist can't spawn — skip the
113
+ // spawnSync call entirely rather than let it throw ENOENT.
114
+ if (bin.includes('/') || bin.includes('\\')) {
115
+ if (!fs.existsSync(bin)) return null;
116
+ }
117
+ let r;
118
+ try {
119
+ r = cp.spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: PROBE_TIMEOUT_MS });
120
+ } catch { return null; }
121
+ if (r.error || r.status !== 0) return null;
122
+ const out = (r.stdout || r.stderr || '');
123
+ if (!/Chrom(e|ium)/i.test(out)) return null;
124
+ return { ok: true, chrome: bin };
125
+ }
126
+
127
+ export function resetChromeProbe() { _capability = null; }
128
+
129
+ export function probeChromeAvailable() {
130
+ if (_capability) return _capability;
131
+
132
+ // An explicit override is authoritative, not just "try this one first
133
+ // then fall back": an operator (or CI environment) who deliberately
134
+ // pointed AGENTIC_SECURITY_CHROME_PATH at a binary wants a failure
135
+ // reported when that binary doesn't work — not a silent switch to
136
+ // whatever else this process happens to auto-detect on the machine.
137
+ const fromEnv = process.env.AGENTIC_SECURITY_CHROME_PATH;
138
+ if (fromEnv) {
139
+ const hit = _tryBinary(fromEnv);
140
+ _capability = hit || { ok: false, reason: 'chrome-path-invalid' };
141
+ return _capability;
142
+ }
143
+
144
+ for (const bin of _candidatePaths()) {
145
+ const hit = _tryBinary(bin);
146
+ if (hit) { _capability = hit; return _capability; }
147
+ }
148
+ _capability = { ok: false, reason: 'no-chrome-found' };
149
+ return _capability;
150
+ }
@@ -47,6 +47,30 @@ import presetTypescript from '@babel/preset-typescript';
47
47
  let _nodeIdSeq = 0;
48
48
  function nextNodeId() { return 'n' + (++_nodeIdSeq); }
49
49
 
50
+ // Round-5 lineage-engine finding, generalized in round 6: resolve a Babel
51
+ // object-property-shaped node's key (`ObjectProperty` from either an
52
+ // `ObjectExpression` or an `ObjectPattern` — both share the same
53
+ // `{computed, key}` shape) to either its real, distinct property name, or
54
+ // the explicit unknown marker '*' when the key is computed and not itself
55
+ // a resolvable literal. A computed key that IS a literal (`{[42]: v}`,
56
+ // `{['literal']: v}` — Babel still marks these `computed: true`) still
57
+ // resolves to that literal's own string form; a computed key that is an
58
+ // arbitrary expression (most commonly an Identifier, e.g. `{[field]: v}`)
59
+ // resolves to '*', NEVER to the key expression's own variable name — using
60
+ // `p.key.name` unconditionally there would attribute the property to the
61
+ // key expression's OWN name as if it were a real, distinct property,
62
+ // colliding e.g. `{ field: a, [field]: b }` onto one shared 'field' key.
63
+ // Originally fixed only for `ObjectExpression` (round 5); round 6 found the
64
+ // identical bug, unfixed, in `lhsPath`'s `ObjectPattern` (destructuring)
65
+ // branch and extracted this shared helper so a third instance of the same
66
+ // bug can't appear in some future object-key-reading code path. See
67
+ // DESIGN_INTRAPROCEDURAL.md §4 and CLAUDE.md's "three hop types" note.
68
+ function resolveObjectKey(p) {
69
+ return p.computed
70
+ ? (p.key?.value != null ? String(p.key.value) : '*')
71
+ : (p.key?.name || (p.key?.value != null ? String(p.key.value) : '*'));
72
+ }
73
+
50
74
  // Compact a Babel AST node into our exprDesc.
51
75
  function exprOf(n) {
52
76
  if (!n) return { kind: 'unknown' };
@@ -91,10 +115,50 @@ function exprOf(n) {
91
115
  return { kind: 'union', branches: [exprOf(n.consequent), exprOf(n.alternate)] };
92
116
  case 'ObjectExpression': return {
93
117
  kind: 'object',
94
- props: (n.properties || []).filter(p => p.type === 'ObjectProperty' && p.key).map(p => ({
95
- key: p.key.name || (p.key.value != null ? String(p.key.value) : '*'),
96
- value: exprOf(p.value),
97
- })),
118
+ // Round-5 lineage-engine finding: for a COMPUTED property
119
+ // (`{[k]: v}`, `p.computed === true`), `p.key` is an arbitrary
120
+ // expression node, not a name — and an Identifier key expression
121
+ // (`k`) HAS a `.name` ('k'), same as a real, non-computed property
122
+ // literally named `k` would. Using `p.key.name` unconditionally
123
+ // therefore attributed a computed property to the key EXPRESSION's
124
+ // own variable name as if it were a real, distinct property name,
125
+ // colliding `{ k: user.ssn, [k]: user.email }` onto one shared 'k'
126
+ // key. A computed key IS still resolvable when it's itself a literal
127
+ // (`{[42]: v}`, `{['literal']: v}` — Babel marks these
128
+ // `computed: true` too, but `p.key.value` gives the real value), so
129
+ // only an Identifier/expression computed key (no `.value`) falls
130
+ // back to the literal string '*' — mirroring the EXISTING computed-
131
+ // MEMBER-access convention (`n.computed` handling just above, and
132
+ // `lhsPath`'s computed-write handling below) rather than inventing a
133
+ // new one. See DESIGN_INTRAPROCEDURAL.md §4 and CLAUDE.md's "three
134
+ // hop types" note.
135
+ //
136
+ // A `SpreadElement` (`{...user}`) previously fell out of the
137
+ // `.filter` entirely — `{...user}` and `{}` were byte-identical in
138
+ // the emitted IR, a real, pre-existing false negative (the identity
139
+ // vanishes, not merged, not flattened — found and documented but
140
+ // not fixed by a final review of an earlier plan). Represented here
141
+ // as a distinct `{spread: true, value: <expr>}` shape (no `key`
142
+ // field) rather than a regular `{key, value}` property, so
143
+ // `scanner/src/lineage/engine.js`'s `resolveExprIdentities` can
144
+ // merge the spread source's OWN field structure directly into this
145
+ // object's structure (see that file for the full mechanism) — a
146
+ // DIFFERENT, more precise handling than a computed-unknown-key
147
+ // property gets (that case folds into one opaque residual bucket; a
148
+ // spread's contents are fully known, just not yet assigned a key in
149
+ // THIS literal, so they can and should stay field-distinguished).
150
+ // Every consumer of `expr.props` outside this package
151
+ // (scanner/src/dataflow/engine.js, higher-order.js,
152
+ // privacy-deep-walker.js — audited before this change) reads only
153
+ // `p.value`, never `p.key`, so this shape is compatible with all of
154
+ // them without any change there — and several gain correct taint/
155
+ // privacy detection through object spread as a direct result, since
156
+ // the spread's value is no longer silently absent from `props`.
157
+ props: (n.properties || []).map(p => {
158
+ if (p.type === 'SpreadElement') return { spread: true, value: exprOf(p.argument) };
159
+ if (p.type !== 'ObjectProperty' || !p.key) return null;
160
+ return { key: resolveObjectKey(p), value: exprOf(p.value) };
161
+ }).filter(Boolean),
98
162
  };
99
163
  case 'ArrayExpression': return { kind: 'array', elements: (n.elements || []).map(exprOf) };
100
164
  case 'SpreadElement': return exprOf(n.argument);
@@ -170,10 +234,33 @@ function lhsPath(n) {
170
234
  return base + '.' + prop;
171
235
  }
172
236
  if (n.type === 'ObjectPattern') {
173
- // Destructured: return an array of (key, alias) pairs the caller can iterate.
237
+ // Destructured: return an array of (key, alias) pairs the caller can
238
+ // iterate. Round 6: uses the same `resolveObjectKey` helper
239
+ // `ObjectExpression` uses above, so a non-literal computed key
240
+ // (`const { [field]: value } = user`) resolves to '*' instead of
241
+ // fabricating a colliding key from the key variable's own name — see
242
+ // `resolveObjectKey`'s header comment for the full rationale.
243
+ //
244
+ // A `RestElement` (`const {a, ...rest} = obj`) has its bound identifier
245
+ // at `.argument`, not `.value` (only a real `ObjectProperty` has
246
+ // `.value`) — using `.value` unconditionally here made `lhsPath`
247
+ // resolve to `null` for every rest binding (`lhsPath(undefined)` hits
248
+ // the `if (!n) return null;` guard before ever reaching the
249
+ // `RestElement` case below), so the consumer's `if (!alias) continue;`
250
+ // guard silently skipped it entirely: a real, pre-existing FR-306
251
+ // violation where the rest binding vanished with no trace, not merged,
252
+ // not flattened. `resolveObjectKey(p)` already correctly resolves to
253
+ // '*' for a RestElement (it has no `.key`/`.computed`), which is
254
+ // exactly the right marker: the consumer already treats a `'*'`-keyed
255
+ // destructured binding as a computed-unknown-key selection
256
+ // (`{kind:'member', object: initExpr, prop: '*'}`), and the lineage
257
+ // engine's existing wildcard-selection machinery (built for `obj[k]`)
258
+ // already resolves that correctly — conservatively aggregating the
259
+ // source's full field set, flagged widened. No engine-level change
260
+ // needed for this fix.
174
261
  return { kind: 'object-pattern', props: (n.properties || []).map(p => ({
175
- key: p.key?.name || (p.key?.value != null ? String(p.key.value) : '*'),
176
- alias: lhsPath(p.value),
262
+ key: resolveObjectKey(p),
263
+ alias: lhsPath(p.type === 'RestElement' ? p.argument : p.value),
177
264
  }))};
178
265
  }
179
266
  if (n.type === 'ArrayPattern') {