@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.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 (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/1336.index.js +16010 -0
  3. package/dist/agentic-security.mjs +80 -80
  4. package/dist/agentic-security.mjs.sha256 +1 -1
  5. package/package.json +7 -6
  6. package/src/dataflow/CLAUDE.md +1 -1
  7. package/src/dataflow/backward.js +104 -7
  8. package/src/dataflow/catalog.js +802 -13
  9. package/src/dataflow/cross-service-taint.js +154 -11
  10. package/src/dataflow/engine.js +875 -90
  11. package/src/dataflow/exploit-prover.js +111 -23
  12. package/src/dataflow/incremental.js +13 -0
  13. package/src/dataflow/index.js +29 -6
  14. package/src/dataflow/path-feasibility.js +86 -9
  15. package/src/dataflow/string-domain.js +168 -0
  16. package/src/dataflow/summaries.js +69 -8
  17. package/src/engine.js +118 -9
  18. package/src/ir/CLAUDE.md +2 -1
  19. package/src/ir/balanced-call.js +33 -1
  20. package/src/ir/callgraph.js +26 -2
  21. package/src/ir/class-hierarchy.js +261 -7
  22. package/src/ir/csharp-ir.js +97 -8
  23. package/src/ir/index.js +21 -1
  24. package/src/ir/parser-cs.js +706 -24
  25. package/src/ir/parser-java.js +498 -17
  26. package/src/ir/parser-js.js +22 -0
  27. package/src/ir/parser-php.js +446 -43
  28. package/src/ir/parser-rust.js +1338 -0
  29. package/src/ir/php-include-merge.js +190 -0
  30. package/src/lineage/sink-registry.js +18 -0
  31. package/src/lineage/source-registry.js +23 -0
  32. package/src/posture/csharp-analysis.js +341 -43
  33. package/src/posture/finding-defaults.js +5 -1
  34. package/src/posture/fix-coverage.js +1 -0
  35. package/src/posture/proof-coverage.js +1 -0
  36. package/src/report/index.js +12 -0
  37. package/src/sast/CLAUDE.md +1 -1
  38. package/src/sast/authz.js +144 -1
  39. package/src/sast/crypto-protocol.js +65 -7
  40. package/src/sast/csharp-cross-file.js +125 -0
  41. package/src/sast/csharp-structural.js +78 -2
  42. package/src/sast/csharp.js +574 -42
  43. package/src/sast/java-ast-folding.js +268 -11
  44. package/src/sast/java-bench-extras.js +530 -2
  45. package/src/sast/java-deserialization.js +31 -2
  46. package/src/sast/java-structural-cross-file.js +146 -0
  47. package/src/sast/java-structural.js +499 -6
  48. package/src/sast/ldap-injection.js +133 -3
  49. package/src/sast/rust.js +57 -0
  50. package/src/sast/xss-reflected-multilang.js +252 -3
  51. package/src/dataflow/smt-feasibility.js +0 -189
@@ -91,9 +91,26 @@ export class SummaryCache {
91
91
  _key(qid, taintedParams, receiverType) {
92
92
  // P1.2: when a receiver type is provided, extend the cache key with
93
93
  // its hash. Backward-compatible: no receiverType → same key as before.
94
- let base = `${qid}::${_hashState(taintedParams)}`;
95
- // R2: append the caller qid when call-string sensitivity is enabled.
96
- if (this._callString && this._callerCtx) base = `${base}@${this._callerCtx}`;
94
+ const stateHash = _hashState(taintedParams);
95
+ let base = `${qid}::${stateHash}`;
96
+ // R2: append the caller qid when call-string sensitivity is enabled —
97
+ // but ONLY for a NON-EMPTY entry state. The EMPTY-entry baseline is the
98
+ // one summary `engine.js`'s outer fixed-point pre-pass loop shares and
99
+ // progressively refines across BOTH multiple passes AND every call path
100
+ // that reaches a function with no tainted args — splitting it by caller
101
+ // breaks that sharing outright: a 3-hop empty-entry mutation chain
102
+ // (leaf mutates its own param unconditionally, mid calls leaf, top calls
103
+ // mid) stopped propagating entirely once enabled, confirmed via direct
104
+ // instrumentation, not a hypothetical concern — the pre-pass loop's own
105
+ // `bMid`/`cLeaf` iterations write to a caller-LESS key nothing else ever
106
+ // reads once a NESTED call from a different caller starts writing to a
107
+ // caller-SUFFIXED key instead. A non-empty entry state has no such
108
+ // shared-convergence dependency to begin with — the value-context cache
109
+ // (`AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`) already computes each distinct
110
+ // tainted-arg shape independently, lazily, per call site, so adding a
111
+ // caller dimension there only ever narrows an already-isolated lookup,
112
+ // never disconnects a shared one.
113
+ if (this._callString && this._callerCtx && stateHash !== 'empty') base = `${base}@${this._callerCtx}`;
97
114
  if (!receiverType) return base;
98
115
  return `${base}::${hashReceiverType(receiverType)}`;
99
116
  }
@@ -103,7 +120,8 @@ export class SummaryCache {
103
120
  }
104
121
 
105
122
  set(qid, taintedParams, summary, receiverType) {
106
- this._cache.set(this._key(qid, taintedParams, receiverType), summary);
123
+ const k = this._key(qid, taintedParams, receiverType);
124
+ this._cache.set(k, summary);
107
125
  }
108
126
 
109
127
  has(qid, taintedParams, receiverType) {
@@ -172,8 +190,16 @@ export class SummaryCache {
172
190
  // Helper: apply a summary to a caller's taint state given the call site's
173
191
  // argument bindings. Returns { calleeReturnTainted, mutated: Set of caller-side
174
192
  // var names that should become tainted because the callee mutated them }.
175
- applyAtCallSite(summary, paramNames, callArgs, callerTaintedVars) {
176
- if (!summary) return { returnTainted: false, mutated: new Set() };
193
+ // `receiverPath` (optional, next-gen taint capability #3): the caller-side
194
+ // access path of a receiver-qualified call's OWN receiver (`bad` for
195
+ // `bad.setData(x)`). When present and `summary.mutatedThisFields` is
196
+ // non-empty, also returns `mutatedThisPaths` — the receiver's own field
197
+ // paths (`bad.data`) to taint in the caller, mirroring `mutated` above but
198
+ // for the implicit `this` parameter instead of a declared one. Omitting
199
+ // `receiverPath` (every pre-existing call site) leaves `mutatedThisPaths`
200
+ // empty, so this is purely additive for callers that don't pass it.
201
+ applyAtCallSite(summary, paramNames, callArgs, callerTaintedVars, receiverPath) {
202
+ if (!summary) return { returnTainted: false, mutated: new Set(), mutatedThisPaths: new Set() };
177
203
  const mutated = new Set();
178
204
  if (summary.mutatedParams && summary.mutatedParams.size) {
179
205
  // Map each mutated parameter position back to the caller-side argument name.
@@ -184,7 +210,11 @@ export class SummaryCache {
184
210
  if (arg && arg.kind === 'ident') mutated.add(arg.name);
185
211
  }
186
212
  }
187
- return { returnTainted: !!summary.returnTainted, mutated };
213
+ const mutatedThisPaths = new Set();
214
+ if (receiverPath && summary.mutatedThisFields && summary.mutatedThisFields.size) {
215
+ for (const field of summary.mutatedThisFields) mutatedThisPaths.add(`${receiverPath}.${field}`);
216
+ }
217
+ return { returnTainted: !!summary.returnTainted, mutated, mutatedThisPaths };
188
218
  }
189
219
 
190
220
  size() { return this._cache.size; }
@@ -208,6 +238,16 @@ function _summaryEq(a, b) {
208
238
  const bm = b.mutatedParams || new Set();
209
239
  if (am.size !== bm.size) return false;
210
240
  for (const p of am) if (!bm.has(p)) return false;
241
+ // Next-gen taint capability #3's `mutatedThisFields` is a THIRD summary
242
+ // field alongside `returnTainted`/`mutatedParams` — deliberately compared
243
+ // here too, not left out, per this function's own header comment above:
244
+ // omitting a field from this equality check is exactly how a fresher,
245
+ // more-refined summary can be silently discarded by the fixed-point loop
246
+ // while still being returned to its immediate caller.
247
+ const atf = a.mutatedThisFields || new Set();
248
+ const btf = b.mutatedThisFields || new Set();
249
+ if (atf.size !== btf.size) return false;
250
+ for (const f of atf) if (!btf.has(f)) return false;
211
251
  return true;
212
252
  }
213
253
 
@@ -244,10 +284,31 @@ export function entryStateFromCall(paramNames, callArgs, callerTaintedVars, isAr
244
284
  // ASSIGNED locals, not raw globals) took the member branch, found
245
285
  // nothing, and — being an else-if — never reached isArgTaintedExpr at
246
286
  // all. Confirmed via a real corpus fixture.
287
+ // PRD W2 (SARD_80_F1_EXECUTION_PRD.md) — an object argument whose FIELD
288
+ // was tainted before the call (`c.field = tainted; step(c);`) is a
289
+ // documented scope gap: entry-state granularity here is param-level,
290
+ // not per-access-path, so `c` itself was never marked tainted even
291
+ // though `c.field` genuinely is in the caller's field-sensitive lattice
292
+ // — the callee then reads `c.field` back out with no provenance at all.
293
+ // Found sampling real Juliet variant 67 false negatives (a tainted
294
+ // value stored into a `Container` object's field, the object passed
295
+ // whole to another function, the field read back out there). Widens
296
+ // the SAME direction this codebase already takes for "unknown-key
297
+ // container writes" and collection taint (dataflow/CLAUDE.md's own
298
+ // documented precedent): if ANY access path prefixed by this
299
+ // identifier is tainted, treat the whole object as tainted when it
300
+ // crosses a call boundary — over-approximating (a clean sibling field
301
+ // read downstream may falsely inherit taint) is the accepted direction
302
+ // for a security scanner, the same tradeoff every other "widen to the
303
+ // container" rule in this codebase already makes.
247
304
  let tainted = false;
248
305
  if (arg.kind === 'ident' && callerTaintedVars.has(arg.name)) {
249
306
  tainted = true;
250
- } else if (arg.kind === 'member' && arg.object?.kind === 'ident') {
307
+ } else if (arg.kind === 'ident') {
308
+ const prefix = `${arg.name}.`;
309
+ for (const v of callerTaintedVars) { if (v.startsWith(prefix)) { tainted = true; break; } }
310
+ }
311
+ if (!tainted && arg.kind === 'member' && arg.object?.kind === 'ident') {
251
312
  const base = arg.object.name;
252
313
  if (callerTaintedVars.has(base) || callerTaintedVars.has(`${base}.${arg.prop}`)) tainted = true;
253
314
  }
package/src/engine.js CHANGED
@@ -107,6 +107,8 @@ import { scanFileUpload } from './sast/file-upload.js';
107
107
  import { scanHostHeader } from './sast/host-header.js';
108
108
  import { scanPythonSinks } from './sast/python-sinks.js';
109
109
  import { scanCSharp } from './sast/csharp.js';
110
+ import { scanCsharpCrossFile } from './sast/csharp-cross-file.js';
111
+ import { computeJavaStructuralCrossFileSuppressions } from './sast/java-structural-cross-file.js';
110
112
  import { scanCpp } from './sast/cpp.js';
111
113
  import { scanJulietShape, applyJulietJavaSuppressions, applyJulietCsSuppressions } from './sast/juliet-shape.js';
112
114
  import { scanCppDataflow, _parseErrorCount as _cppDataflowParseErrors } from './sast/cpp-dataflow.js';
@@ -237,7 +239,6 @@ import { runCrossServiceTaint } from './dataflow/cross-service-taint.js';
237
239
  import { annotateRuntimeCorrelation } from './posture/runtime-correlation.js';
238
240
  import { applyLearnedCalibration } from './posture/triage-learning.js';
239
241
  import { annotateFormalVerification } from './dataflow/formal-verify.js';
240
- import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
241
242
  import { annotateProofGate } from './dataflow/proof-gate.js';
242
243
  import { applySanitizerGate } from './dataflow/sanitizer-gate.js';
243
244
  import { annotateFalsification } from './posture/falsification.js';
@@ -1551,7 +1552,11 @@ function _hasSsrfHostGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _SSR
1551
1552
  // before `Path.Combine(..., filename)`) left the finding firing on the FIXED
1552
1553
  // revision. Safe to match on shape because _guardMatchNearSinkIdentifier
1553
1554
  // already requires the guard to name the same identifier as the sink.
1554
- const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b|\b(?:[Ee]nsure|[Vv]alidate|[Aa]ssert|[Rr]equire|[Vv]erify|[Cc]heck)\w{1,40}\s*\(/;
1555
+ // `starts_with`/`strip_prefix` (Rust's own snake_case naming) and
1556
+ // `canonicalize` (Rust's `Path::canonicalize`, the containment check's
1557
+ // resolve-then-compare half) join the existing camelCase/PascalCase forms —
1558
+ // same guard, a different language's naming convention.
1559
+ const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix|starts_with|strip_prefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bcanonicalize\s*\(|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b|\b(?:[Ee]nsure|[Vv]alidate|[Aa]ssert|[Rr]equire|[Vv]erify|[Cc]heck)\w{1,40}\s*\(/;
1555
1560
  function _hasPathGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _PATH_GUARD_RE); }
1556
1561
 
1557
1562
  // Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
@@ -5007,7 +5012,17 @@ function scanJavaSAST(fp, raw) {
5007
5012
  // Per-family file-level predicate (custom logic). Returns truthy to suppress.
5008
5013
  if (typeof rule.fileSafePredicate === 'function' && rule.fileSafePredicate(cleaned, raw)) continue;
5009
5014
 
5010
- const sinkLine = cleaned.substring(0, sinkMatch.index).split('\n').length;
5015
+ // Use the match's END, not its start: every sinkRe here terminates
5016
+ // right at the sink call's own opening `(`, but a few (ldap-injection's
5017
+ // `\bjavax.naming...\b[\s\S]{0,12000}?\w+\.search\(`) anchor on an EARLIER
5018
+ // token (an import statement, or the first `DirContext` mention) and lazily
5019
+ // span forward to the real call, which can be dozens of lines later. Using
5020
+ // the start position reported the anchor's line (e.g. an `import` line) as
5021
+ // the "vulnerable line" — a wrong, misleading finding location/snippet, and
5022
+ // it also broke `dedupeFindingsWithEvidence`'s per-sink-line collapse with
5023
+ // other detectors (ldap-injection.js, the IR-TAINT engine) that correctly
5024
+ // report the real call site, since a stale anchor line never matches it.
5025
+ const sinkLine = cleaned.substring(0, sinkMatch.index + sinkMatch[0].length).split('\n').length;
5011
5026
 
5012
5027
  // Optional precision filter: when the rule is taint-aware, extract the
5013
5028
  // sink call's argument expression and check whether a tainted variable
@@ -6372,6 +6387,9 @@ const _VULN_FAMILY_PREFIX = [
6372
6387
  ['SQL Injection', 'sql-injection'],
6373
6388
  ['Command Injection', 'command-injection'],
6374
6389
  ['Code Injection', 'code-injection'],
6390
+ ['LDAP Injection', 'ldap-injection'],
6391
+ ['XPath Injection', 'xpath-injection'],
6392
+ ['Local/Remote File Inclusion', 'code-injection'],
6375
6393
  ['Reflected XSS', 'xss'],
6376
6394
  ['Stored XSS', 'xss'],
6377
6395
  ['DOM XSS', 'xss'],
@@ -6383,6 +6401,18 @@ const _VULN_FAMILY_PREFIX = [
6383
6401
  ['Prototype Pollution', 'prototype-pollution'],
6384
6402
  ['IDOR', 'idor'],
6385
6403
  ['Potential IDOR', 'idor'],
6404
+ // SARD_80_F1 W5.13 — must precede the generic 'AuthZ:' catch-all below: a
6405
+ // CWE-862 (Missing Authorization) finding from authz.js's PHP IDOR
6406
+ // detector also starts with "AuthZ:", and this array's first-match-wins
6407
+ // iteration would otherwise sweep it into the SAME 'idor' family as every
6408
+ // OTHER authz.js pattern (JWT/OAuth/session-fixation/CWE-639), overriding
6409
+ // finding-defaults.js's own correct CWE-862 -> 'missing-authz' mapping —
6410
+ // confirmed as the exact reason a genuinely firing, correctly-CWE-tagged
6411
+ // finding never scored a tp (dedupeFindingsWithEvidence runs before the
6412
+ // finding-defaults backfill and stamps `f.family` first).
6413
+ ['AuthZ: raw SQL where-by-id from request input without an ownership check', 'missing-authz'],
6414
+ ['AuthZ: fopen() on request-supplied path/id without an ownership check', 'missing-authz'],
6415
+ ['AuthZ: XPath query built from request input without an ownership check', 'missing-authz'],
6386
6416
  ['AuthZ:', 'idor'],
6387
6417
  ['MD5/SHA1', 'weak-crypto'],
6388
6418
  ['Weak Cryptographic Hash', 'weak-crypto'],
@@ -6396,6 +6426,41 @@ const _VULN_FAMILY_PREFIX = [
6396
6426
  ['OpenSSH Private Key', 'hardcoded-secret'],
6397
6427
  ['Password in URL', 'hardcoded-secret'],
6398
6428
  ['OAuth Authorization Code Theft', 'open-redirect'],
6429
+ // SARD_80_F1 W4: every IR-TAINT open-redirect catalog sink names its own
6430
+ // API in the vuln string ("Open Redirect (Controller.Redirect)",
6431
+ // "Open Redirect (response.sendRedirect)", "Open Redirect (flask.redirect)",
6432
+ // "Open Redirect (Rails redirect_to)", ...) — with no prefix entry here,
6433
+ // each one fell through to the generic auto-slug fallback and got its OWN
6434
+ // unique family string, distinct from open-redirect.js's/csharp.js's
6435
+ // explicit `family: 'open-redirect'`. Two detectors correctly finding the
6436
+ // SAME real vulnerability at the SAME line then failed to collapse via
6437
+ // dedupeFindingsWithEvidence's (file, sink-line, family) key, producing an
6438
+ // extra unmatched/scored FP — the exact bug class found in W4.J15 for
6439
+ // Java's LDAP rule, here caused by family-string divergence instead of a
6440
+ // wrong line. Found via the C# public mirror: CWE601_Open_Redirect__Web_
6441
+ // QueryString_Web_01.cs's resp.Redirect(data) produced both a `CSHARP`
6442
+ // finding (family 'open-redirect') and an `IR-TAINT` finding (family
6443
+ // 'open-redirect-controller-redirect') at the identical line.
6444
+ ['Open Redirect', 'open-redirect'],
6445
+ // Same bug, same fix, found by checking every other injection family with
6446
+ // BOTH a structural detector AND IR-TAINT catalog sinks for the identical
6447
+ // divergence: every CWE-113 IR-TAINT sink's vuln string starts with "HTTP
6448
+ // Response Splitting" (12 catalog entries across Java/JS/PHP/Go/C#), with
6449
+ // no prefix entry here — each fell through to its own unique auto-slug
6450
+ // family, distinct from `response-splitting.js`'s explicit
6451
+ // `family: 'response-splitting'`. Verified directly: a Java servlet
6452
+ // `response.setHeader("X-User", request.getParameter(name))` probe produced
6453
+ // both a `RESPONSE-SPLITTING` finding (family 'response-splitting') and an
6454
+ // `IR-TAINT` finding (family 'http-response-splitting-header-injection') at
6455
+ // the identical line. CWE-113 has carried this session's largest persistent
6456
+ // fp counts (399 on Java dev, 348 on C# train, per W4.C1) and this exact
6457
+ // duplicate-detection mechanism was never checked as a contributing cause
6458
+ // during the earlier sanitizer-focused CWE-113 investigation (commit
6459
+ // 4f41daec) — distinct from `test/benchmark/expected.json`'s `_familyMap`
6460
+ // (the SEPARATE table that resolves scoring family from vuln text and
6461
+ // already maps this same prefix to 'header-hardening'; this table only
6462
+ // controls ENGINE-level dedup, not scoring attribution).
6463
+ ['HTTP Response Splitting', 'response-splitting'],
6399
6464
  ['Synchronous Blocking I/O', 'dos-sync-io'],
6400
6465
  ['Missing Timeout', 'dos-no-timeout'],
6401
6466
  ['GraphQL Missing Query', 'graphql-dos'],
@@ -9116,6 +9181,25 @@ function _deterministicFileTimings(timings) {
9116
9181
  }
9117
9182
  const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
9118
9183
  aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
9184
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
9185
+ // SARD_80_F1 W5.41/W5.42 — unlike the ADD-only cross-file passes above,
9186
+ // this one SUPPRESSES an already-emitted java-structural.js or
9187
+ // java-bench-extras.js finding when every real caller (checked project-
9188
+ // wide within the finding's own directory) agrees the concatenated/
9189
+ // redirected value is a literal. See java-structural-cross-file.js's own
9190
+ // header comment for the full design and W5.40's EXECUTION_STATUS.md
9191
+ // entry for the root-cause investigation.
9192
+ {
9193
+ const _jsCrossSuppress = runDetector(_detectorErrors,'<project>','computeJavaStructuralCrossFileSuppressions',()=>computeJavaStructuralCrossFileSuppressions(fc));
9194
+ if (_jsCrossSuppress && _jsCrossSuppress.size) {
9195
+ for (let _i = aF.length - 1; _i >= 0; _i--) {
9196
+ const _f = aF[_i];
9197
+ if (_f && typeof _f.id === 'string' && (_f.id.startsWith('java-struct-') || _f.id.startsWith('java-extras:open-redirect:')) && _jsCrossSuppress.has(`${_f.file}:${_f.line}`)) {
9198
+ aF.splice(_i, 1);
9199
+ }
9200
+ }
9201
+ }
9202
+ }
9119
9203
  aF.push(...(runDetector(_detectorErrors,'<project>','scanStoredPromptInjectionCrossFile',()=>scanStoredPromptInjectionCrossFile(fc))||[]));
9120
9204
  // Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
9121
9205
  // AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
@@ -9754,11 +9838,16 @@ function _deterministicFileTimings(timings) {
9754
9838
  if (process.env.AGENTIC_SECURITY_FORMAL === '1') {
9755
9839
  await _runAnnotator("annotateFormalVerification", async () => { await annotateFormalVerification(finalFindings, fc, {}); });
9756
9840
  }
9757
- // SMT path feasibility: Z3-backed proof of reachability. Opt-in via
9758
- // AGENTIC_SECURITY_SMT_FEASIBILITY=1.
9759
- if (process.env.AGENTIC_SECURITY_SMT_FEASIBILITY === '1') {
9760
- await _runAnnotator("annotatePathFeasibility", async () => { await annotatePathFeasibility(finalFindings, {}); });
9761
- }
9841
+ // SMT path feasibility (`AGENTIC_SECURITY_SMT_FEASIBILITY`) was retired:
9842
+ // its predicate encoder read `f.chain`/`f.taintPath` as a sequential
9843
+ // source-to-sink path, but those fields are actually a flat list of
9844
+ // SOURCES reaching the sink (`_sourcesReachingExpr`), never a path —
9845
+ // confirmed by direct code reading, never fixed by real data. It also
9846
+ // mutated `f.severity` directly on an 'unsat' verdict, violating this
9847
+ // codebase's own recall-preserving-demotion convention that every other
9848
+ // proof gate here follows (see `dataflow/CLAUDE.md`). Superseded by the
9849
+ // real backward-slice-driven mechanism: `dataflow/exploit-prover.js`'s
9850
+ // `proveExploits`, opt-in via `AGENTIC_SECURITY_SYMEXEC=1`.
9762
9851
  // Privacy / PII taint: emits pii-exposure findings + DPIA artifact.
9763
9852
  // FR-405 (assurance-hardening PRD): _privacyIrBacked is declared at
9764
9853
  // function scope above the AGENTIC_SECURITY_NO_INTEGRATION block, not
@@ -10276,7 +10365,27 @@ function _deterministicFileTimings(timings) {
10276
10365
  // SCA-SAST correlation: link SAST findings to SCA vulnerable packages
10277
10366
  try{for(const f of finalFindings){if(!f.chain||!f.chain.length)continue;const src=f.chain[0]?.label||'';for(const sc of supplyChain){if(sc.type!=='vulnerable_dep')continue;if(src.includes(sc.name)||f.vuln?.toLowerCase().includes(sc.name)){f.scaCorrelation={osvId:sc.osvId,package:sc.name,version:sc.version,confirmed:true};sc.sastConfirmed=true;break;}}}}catch(_){}
10278
10367
  // Multi-sink chain detection: group findings by source variable
10279
- try{const srcGroups=new Map();for(const f of finalFindings){const src=f.chain?.[0]?.label;if(!src)continue;if(!srcGroups.has(src))srcGroups.set(src,[]);srcGroups.get(src).push(f);}for(const[src,group]of srcGroups){if(group.length<2)continue;const groupId=`multi-sink:${src}:${group.length}`;for(const f of group)f._multiSinkGroupId=groupId;finalFindings.push({id:groupId,file:group[0].file,line:group[0].line,vuln:`Multi-Sink Taint Chain — ${src} reaches ${group.length} sinks`,severity:group.some(f=>f.severity==='critical')?'critical':'high',cwe:'CWE-20',parser:'MULTI-SINK',confidence:0.85,sinks:group.map(f=>({file:f.file,line:f.line,vuln:f.vuln})),_aggregated:true});}}catch(_){}
10368
+ // SARD_80_F1 W4.J22/J23: this aggregate previously ALWAYS pushed a brand
10369
+ // new finding hardcoding cwe:'CWE-20' with no `family`, regardless of what
10370
+ // the grouped sinks actually were. When every sink in the group shares one
10371
+ // real family/CWE — the common case: several sinks along the same
10372
+ // injection-class chain, e.g. the same tainted var reaching two
10373
+ // db.query() calls — that new finding duplicated a per-sink finding
10374
+ // ALREADY in `finalFindings` at the identical (file, line, family, cwe):
10375
+ // a first attempt here just relabeled the aggregate to the shared
10376
+ // family/CWE, expecting it to collapse via dedupeFindingsWithEvidence's
10377
+ // (file, sink-line, family) key — but this pass runs AFTER dedupe
10378
+ // (line ~9467), so the relabeled duplicate was never actually removed; a
10379
+ // real corpus A/B confirmed zero net fp movement, only a relabel. Fixed
10380
+ // properly: in the homogeneous case, don't push a second finding at all —
10381
+ // attach the chain metadata (source + full sink list) onto the existing
10382
+ // per-sink finding instead, so the "one source, N sinks" information
10383
+ // survives without a duplicate, separately-scored finding. Only a
10384
+ // genuinely mixed-CWE chain (a real, distinct "multiple vulnerability
10385
+ // classes from one source" finding, not a duplicate of any single
10386
+ // existing finding) still gets its own standalone finding, tagged with
10387
+ // the honest generic CWE-20/'multi-sink-taint-chain' label.
10388
+ try{const srcGroups=new Map();for(const f of finalFindings){const src=f.chain?.[0]?.label;if(!src)continue;if(!srcGroups.has(src))srcGroups.set(src,[]);srcGroups.get(src).push(f);}for(const[src,group]of srcGroups){if(group.length<2)continue;const groupId=`multi-sink:${src}:${group.length}`;for(const f of group)f._multiSinkGroupId=groupId;const sharedFamily=group.every(f=>f.family&&f.family===group[0].family)?group[0].family:null;const sharedCwe=group.every(f=>f.cwe&&f.cwe===group[0].cwe)?group[0].cwe:null;const sinks=group.map(f=>({file:f.file,line:f.line,vuln:f.vuln}));if(sharedFamily&&sharedCwe){for(const f of group)f.multiSinkChain={source:src,sinkCount:group.length,sinks};continue;}finalFindings.push({id:groupId,file:group[0].file,line:group[0].line,vuln:`Multi-Sink Taint Chain — ${src} reaches ${group.length} sinks`,severity:group.some(f=>f.severity==='critical')?'critical':'high',cwe:'CWE-20',family:'multi-sink-taint-chain',parser:'MULTI-SINK',confidence:0.85,sinks,_aggregated:true});}}catch(_){}
10280
10389
  // SCA transitive dedup: collapse duplicate CVEs across dep chains
10281
10390
  try{const osvGroups=new Map();for(const sc of supplyChain){if(sc.type!=='vulnerable_dep'||!sc.osvId)continue;if(!osvGroups.has(sc.osvId))osvGroups.set(sc.osvId,[]);osvGroups.get(sc.osvId).push(sc);}for(const[osvId,group]of osvGroups){if(group.length<=1)continue;const primary=group.find(s=>s.isDirect)||group[0];primary.dependents=group.filter(s=>s!==primary).map(s=>({name:s.name,version:s.version,depChain:s.depChain,isDirect:s.isDirect}));primary._transitiveDeduped=group.length-1;for(const dup of group){if(dup!==primary)dup._deduplicatedInto=primary.osvId;}supplyChain.splice(0,supplyChain.length,...supplyChain.filter(s=>!s._deduplicatedInto));}}catch(_){}
10282
10391
  // ── World-class post-scan artifact emitters ──────────────────────────
package/src/ir/CLAUDE.md CHANGED
@@ -31,7 +31,8 @@ consumed by `scanner/src/dataflow/` for taint analysis.
31
31
 
32
32
  **Two further gaps found while building the corpus entry above, NOT fixed (out of scope for trailing-lambda body recursion, routed around instead — same pattern as Java's chained-call CFG bug):** (a) a call with NO args made on a tainted receiver via a dotted callee string — e.g. `it.toString()`, `tainted.trim()` — does not inherit the receiver's taint. `engine.js`'s `exprTaint` for `case 'call'` checks only `expr.args` and the resolved callee's own return-taint summary (`_nestedCallReturnTainted`); a callee string like `"it.toString"` is never parsed back into a member expression to ask whether its OWN receiver (`it`) is tainted. This is not Kotlin-specific (any language whose IR lowers a method call to a bare dotted-string callee has the same exposure), so a real fix belongs in `engine.js`, not here. (b) `call.parameters` (Ktor's own cataloged `member`-type source) does not propagate taint through a subsequent `.getAll(...)` call made on top of it — `call.parameters.getAll("id")` reads as untainted, because the catalog entry matches a bare property READ, not a call whose receiver is that property. Both gaps were discovered via a fixture that initially used these exact shapes and silently produced zero findings; each was routed around (by using a no-transform binding and a `call`-type source instead) rather than fixed, to keep this task scoped to CFG body recursion. **Taint-recall PRD (80%) Tier 5: `kt-file-readtext`/`kt-url-readtext` catalog cross-collision.** Found via `CVE-2022-22965-kt-ssrf`: both entries were unscoped bare `callee: 'readText'` (path-traversal and SSRF sinks sharing the identical last-segment name), so a single `URL(url).readText()` call fired BOTH — the correct SSRF finding and a spurious Path Traversal finding on the same line. Fixed with receiver scoping (`^File` / `^URL`), the same precision pattern `match.receiver` uses throughout this catalog. Guarded by `test/catalog-path-ssrf-p3.test.js`; required updating `test/phase2-scoping.test.js`'s existing `matchSinkOrSanitizer('readText', 'A.java')` call (a bare string with no receiver context, which the new requirement correctly rejects) to a realistic dotted-string shape (`'File.readText'`). |
33
33
  | C / C++ | `parser-cpp.js` | Hand-rolled parser (functions, qualified names, CFG lowering). Dispatched by extension (`c/cc/cpp/cxx/h/hh/hpp/hxx`) in both `buildProjectIR` and `buildProjectIRAsync`. |
34
- | Long-tail (rust/solidity/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. |
34
+ | Rust | `parser-rust.js` | Hand-rolled, the `parser-go.js`/`parser-cs.js` pattern (preprocess: comments blanked, `::` paths flattened to dots, turbofish/`?`/`.await` stripped; then statement-split + recursive CFG build). **SARD 80% F1 push, first-class from day one — no prior IR existed for Rust at all** (only the opt-in, WASM-based `tree-sitter-loader.js` long-tail path below, which nothing in the taint engine consumes). Models: `fn` items (free/impl/trait, qualifiers, generics, `impl Type` methods named `Type.method`), `let`/destructuring/compound-assign, method chains (args accumulated innermost-first, so `Command::new("sh").arg("-c").arg(x)` has the literal program name at arg 0 regardless of the chain's terminal method), macros (`format!`/`write!`/etc. lower to a template with inline `{ident}` and positional args; `vec!` to an array; every other macro keeps its own `name!` callee so `sqlx::query!` — the compile-checked, parameterized macro — never collides with the `query` sink); `if`/`if let`/`while`/`while let`/`loop`/`for`/`match` (pattern bindings assigned from the scrutinee) including expression-position control flow (`let x = match … {}` yields a `union` of the branch tails); trailing closures inlined with their parameter bound to the receiver; extractor-typed parameters (`Query(q): Query<P>`) and route-attributed handlers (`#[get("/<x>")]`) emit `fn.paramAnnotations`. **`.unwrap()`/`.expect(msg)` are popped from the chain, not just taint-transparent** — leaving them in shifts the terminal segment away from the real sink name (`sqlx::query(&sql).fetch_all(pool).await.unwrap()` would otherwise end in `.unwrap`, never `.fetch_all`, and no catalog entry could ever match it), the same "terminal segment shift" defect class documented for Go/Java/Kotlin above; a macro-only statement whose sole continuation is a popped `.unwrap()` (`write!(w, "…", x).unwrap();`) still counts as a call, tracked via a separate `_baseIsCall` flag so it doesn't fall through to "no call happened here" once the passthrough is gone. Three self-scan-flagged regexes (`(?:#\[[^\]]*\]\s*)+`, the attribute-skip pattern, appearing at three call sites) match the "optional/quantified group flanked by `\s*`" ReDoS heuristic shape but are linear by construction — each iteration is bounded by the mandatory literal `#[`…`]`, no shared character class exists for the engine to backtrack across — confirmed by direct timing (50 000 back-to-back attributes, 200 000 non-matching whitespace chars: both single-digit milliseconds), not merely asserted; see the comment at `_parseParams` in `parser-rust.js`. Guarded by `test/parser-rust.test.js` (CFG shape) and `test/rust-taint-flow.test.js` (end-to-end taint flow + precision cases). **Measured `bench/layer-recall` impact: 0 → 5/5 (100%) whole-corpus, 0 → 3/3 (100%) deep-tier** on first measurement — the corpus is small and every entry is annotation-sourced (axum/actix/rocket extractor parameters route directly into `paramAnnotations`), so 100% reflects the corpus's own composition, not general Rust ecosystem coverage; see `docs/METRICS.md`'s own disclosure. |
35
+ | Long-tail (solidity/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. Rust's own tree-sitter grammar remains loadable here too (unaffected by `parser-rust.js` existing), but the hand-rolled parser above is what feeds the taint engine — this path is now redundant for Rust specifically, kept only because removing a language from `tree-sitter-sinks.js`'s opt-in surface is out of scope for this change. |
35
36
 
36
37
  ## Python parser — dual-path with auto fallback
37
38
 
@@ -22,12 +22,44 @@
22
22
  // simply left unconsumed rather than corrupting anything. Recovering the
23
23
  // first call's real signature is a strict improvement over the prior
24
24
  // choice between silent corruption and no match at all.
25
- export function matchBalancedCall(s, calleeRe) {
25
+ export function matchBalancedCall(s, calleeRe, opts) {
26
26
  if (typeof s !== 'string') return null;
27
27
  const m = calleeRe.exec(s);
28
28
  if (!m || m.index !== 0) return null;
29
29
  let i = m[0].length;
30
30
  while (s[i] === ' ' || s[i] === '\t') i++;
31
+ // SARD_80_F1 W2.3 follow-up (opt-in via `opts.skipGenerics`, used only by
32
+ // parser-cs.js): C#'s `new List<string>()` / `new Dictionary<string,
33
+ // int>()` has a generic type-argument list BETWEEN the class name and the
34
+ // constructor's own `(`. Without this, `new\s+([\w.]+)` matches only
35
+ // "List", the character right after it is `<` (not `(`), and the whole
36
+ // match fails — the constructor call silently lowers to `{kind:'unknown'}`
37
+ // instead of a proper `isNew:true` call, which is why `List<T>` (C#'s
38
+ // dominant MODERN collection idiom, unlike the non-generic `ArrayList`
39
+ // fixed alongside this) never even reached the collection-taint mutator
40
+ // rule at all. Deliberately opt-in, not the default: `<`/`>` are also
41
+ // comparison operators, and Go/PHP/Ruby (this helper's other three
42
+ // callers) have no equivalent "generic-args-before-a-call" shape, so
43
+ // widening the default here would be pure unvalidated risk for them.
44
+ // Scans for BALANCED angle brackets only (handles nested generics like
45
+ // `Dictionary<string, List<int>>`); if it doesn't find a clean balanced
46
+ // `<...>` immediately here, this leaves `i` untouched and falls through
47
+ // to the ordinary `(`-required check below, same as before this existed.
48
+ if (opts && opts.skipGenerics && s[i] === '<') {
49
+ let j = i + 1;
50
+ let depth = 1;
51
+ while (j < s.length && depth > 0) {
52
+ if (s[j] === '<') depth++;
53
+ else if (s[j] === '>') depth--;
54
+ else if (s[j] === '(' || s[j] === ')' || s[j] === ';') { depth = -1; break; } // not a generic-args list — bail
55
+ j++;
56
+ }
57
+ if (depth === 0) {
58
+ let k = j;
59
+ while (s[k] === ' ' || s[k] === '\t') k++;
60
+ if (s[k] === '(') i = k;
61
+ }
62
+ }
31
63
  if (s[i] !== '(') return null;
32
64
  const openIdx = i;
33
65
  let depth = 0;
@@ -59,16 +59,40 @@ export function buildCallGraph(perFileIR, fileContents) {
59
59
  // missing one.
60
60
  const bareTailInFile = new Map(); // file -> Map<bareTail, qid|null>
61
61
 
62
+ // PRD W1 (SARD_80_F1_EXECUTION_PRD.md) — `--scramble-identifiers` renames
63
+ // every class to a lowercase `case_<hash>` token, so the qid's middle
64
+ // `::ClassName::` segment no longer starts with an uppercase letter even
65
+ // though it IS still a real, structurally-declared class. Wherever a
66
+ // file's own parser emits real `ir.classes` facts (Java/C#, today —
67
+ // Python/PHP/Ruby/Go/Kotlin don't yet, tracked as W1.1), trust THAT
68
+ // instead of a capitalization guess: a name is a real class if and only
69
+ // if it was independently parsed as a class declaration, which is exactly
70
+ // as true under scrambled names as under real ones (the class-declaration
71
+ // parser doesn't care about capitalization either). Falls back to the old
72
+ // PascalCase heuristic for files whose language doesn't emit ir.classes
73
+ // yet, so nothing regresses ahead of W1.1 landing per-language.
74
+ const knownClassNamesByFile = new Map();
75
+ for (const file of Object.keys(perFileIR || {})) {
76
+ const ir = perFileIR[file];
77
+ if (ir && Array.isArray(ir.classes) && ir.classes.length) {
78
+ knownClassNamesByFile.set(file, new Set(ir.classes.map(c => c.name)));
79
+ }
80
+ }
81
+
62
82
  for (const file of Object.keys(perFileIR || {})) {
63
83
  const ir = perFileIR[file];
64
84
  if (!ir || !ir.functions) continue;
65
85
  byNameInFile.set(file, new Map());
66
86
  bareTailInFile.set(file, new Map());
87
+ const knownClasses = knownClassNamesByFile.get(file);
67
88
  for (const fn of ir.functions) {
68
89
  functions.set(fn.qid, fn);
69
90
  byNameInFile.get(file).set(fn.name, fn.qid);
70
- const m = fn.qid.match(/::([A-Z]\w*)::(\w+)@/);
71
- if (m) classMethods.set(`${m[1]}.${m[2]}`, fn.qid);
91
+ const m = fn.qid.match(/::(\w+)::(\w+)@/);
92
+ if (m) {
93
+ const isRealClass = knownClasses ? knownClasses.has(m[1]) : /^[A-Z]/.test(m[1]);
94
+ if (isRealClass) classMethods.set(`${m[1]}.${m[2]}`, fn.qid);
95
+ }
72
96
  if (fn.name && fn.name.includes('.')) {
73
97
  const tail = fn.name.split('.').pop();
74
98
  const fileMap = bareTailInFile.get(file);