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

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 (107) hide show
  1. package/CHANGELOG.md +236 -0
  2. package/bin/agentic-security.js +186 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +183 -14
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +14 -14
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +18 -10
  19. package/src/dataflow/CLAUDE.md +10 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/engine.js +324 -60
  22. package/src/dataflow/implicit-flow.js +68 -36
  23. package/src/dataflow/incremental.js +18 -3
  24. package/src/dataflow/index.js +2 -1
  25. package/src/dataflow/proven-clean.js +41 -0
  26. package/src/dataflow/sanitizer-gate.js +35 -9
  27. package/src/dataflow/sanitizer-proof.js +21 -3
  28. package/src/dataflow/stub-aware-filter.js +36 -13
  29. package/src/dataflow/summaries.js +21 -2
  30. package/src/engine.js +202 -42
  31. package/src/ir/CLAUDE.md +4 -1
  32. package/src/ir/balanced-call.js +55 -0
  33. package/src/ir/parser-cs.js +62 -9
  34. package/src/ir/parser-go.js +29 -11
  35. package/src/ir/parser-java.js +96 -19
  36. package/src/ir/parser-js.js +151 -20
  37. package/src/ir/parser-php.js +44 -9
  38. package/src/ir/parser-rb.js +37 -7
  39. package/src/ir/ssa.js +6 -1
  40. package/src/lsp/server.js +28 -2
  41. package/src/mcp/CLAUDE.md +9 -2
  42. package/src/mcp/redact.js +26 -0
  43. package/src/mcp/tools.js +155 -14
  44. package/src/posture/CLAUDE.md +19 -7
  45. package/src/posture/accuracy-scorecard.js +9 -1
  46. package/src/posture/aibom.js +12 -8
  47. package/src/posture/auditor-walkthrough.js +102 -3
  48. package/src/posture/autopilot.js +8 -1
  49. package/src/posture/calibration-drift.js +11 -5
  50. package/src/posture/calibration.js +24 -2
  51. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  52. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  53. package/src/posture/compliance-policy.js +33 -1
  54. package/src/posture/confidence.js +44 -10
  55. package/src/posture/corpus-enroll.js +9 -5
  56. package/src/posture/corpus-match.js +19 -0
  57. package/src/posture/csharp-analysis.js +62 -3
  58. package/src/posture/deploy-platform.js +4 -1
  59. package/src/posture/drift.js +7 -1
  60. package/src/posture/epss.js +13 -1
  61. package/src/posture/evidence-bundle.js +36 -6
  62. package/src/posture/exploitability-probability.js +13 -1
  63. package/src/posture/falsification.js +23 -2
  64. package/src/posture/fix-metrics.js +1 -1
  65. package/src/posture/fix-verify-loop.js +10 -1
  66. package/src/posture/iac-reachability.js +14 -8
  67. package/src/posture/integrity.js +25 -7
  68. package/src/posture/model-rescan.js +65 -0
  69. package/src/posture/mttr.js +5 -0
  70. package/src/posture/poc-inprocess.js +27 -8
  71. package/src/posture/regression-test-gen.js +23 -8
  72. package/src/posture/reverse-blast-radius.js +5 -1
  73. package/src/posture/risk-dollars.js +18 -1
  74. package/src/posture/secret-history.js +20 -11
  75. package/src/posture/security-trend.js +7 -1
  76. package/src/posture/stack-playbook.js +22 -1
  77. package/src/posture/threat-model-grounding.js +2 -2
  78. package/src/posture/validator-metrics.js +10 -3
  79. package/src/posture/verifier.js +32 -57
  80. package/src/report/index.js +183 -14
  81. package/src/runScan.js +1 -1
  82. package/src/sast/_comment-strip.js +15 -4
  83. package/src/sast/_secret-entropy.js +1 -1
  84. package/src/sast/authz.js +6 -4
  85. package/src/sast/bench-shape/index.js +2 -7
  86. package/src/sast/claude-md-prompt-injection.js +14 -3
  87. package/src/sast/cloud-iam.js +60 -7
  88. package/src/sast/cpp-bench-extras.js +1 -1
  89. package/src/sast/csrf.js +7 -5
  90. package/src/sast/env-hygiene.js +5 -2
  91. package/src/sast/iac-terraform.js +25 -0
  92. package/src/sast/java-bench-extras.js +1 -1
  93. package/src/sast/java-constant-fold.js +5 -5
  94. package/src/sast/llm-owasp.js +4 -2
  95. package/src/sast/mcp-audit.js +7 -0
  96. package/src/sast/pipeline.js +8 -0
  97. package/src/sast/prompt-template.js +8 -6
  98. package/src/sast/prototype-pollution.js +6 -2
  99. package/src/sast/redos-nfa.js +6 -6
  100. package/src/sast/secret-concat.js +13 -2
  101. package/src/sast/ssrf-cloud-metadata.js +6 -3
  102. package/src/sast/xss-reflected-multilang.js +1 -1
  103. package/src/sast/xxe.js +1 -1
  104. package/src/sca/CLAUDE.md +3 -4
  105. package/src/sca/container.js +35 -3
  106. package/src/sca/dep-confusion.js +7 -0
  107. package/src/sca/sarif-ingest.js +0 -187
package/src/engine.js CHANGED
@@ -121,7 +121,7 @@ import { scanRuby } from './sast/ruby.js';
121
121
  import { scanPhp } from './sast/php.js';
122
122
  import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
123
123
  // Phase 1 — precision-engineering posture modules.
124
- import { annotateConfidence } from './posture/confidence.js';
124
+ import { annotateConfidence, applyUnvalidatedPenalty } from './posture/confidence.js';
125
125
  import { backfillFindingDefaults } from './posture/finding-defaults.js';
126
126
  import { annotatePocs } from './posture/poc-generator.js';
127
127
  import { annotateExecutionProofs } from './posture/prove-findings.js';
@@ -148,7 +148,7 @@ import { ingestLogicClaims } from './posture/logic-claims.js';
148
148
  import { annotateNarration } from './posture/flow-narration.js';
149
149
  import { applyPathConstraints } from './posture/path-predicates.js';
150
150
  // Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
151
- import { buildProjectIR } from './ir/index.js';
151
+ import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
152
152
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
153
153
  import { runDeepAnalysis } from './dataflow/index.js';
154
154
  // v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
@@ -2214,7 +2214,13 @@ const IAC_PATTERNS = [
2214
2214
  { match: /\$\{\{\s*github\.event\.(?:issue|pull_request)\.title|\$\{\{\s*github\.event\.comment\.body/i,
2215
2215
  fileTypes: /\.github\/workflows\/.*\.ya?ml$/i,
2216
2216
  severity: 'high', cwe: 'CWE-78', vuln: 'GitHub Actions: untrusted github.event input interpolated into shell',
2217
- fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.' },
2217
+ fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.',
2218
+ // Same false-positive as src/sast/pipeline.js's overlapping rule: a pure
2219
+ // `KEY: ${{ github.event.… }}` mapping line assigns to an env var at the
2220
+ // workflow-engine level (the fix's own recommended pattern), not into a
2221
+ // shell command string — only a match embedded in a larger line (a run:
2222
+ // script body) is actually dangerous.
2223
+ lineSafeRe: /^[\w.-]+\s*:\s*\$\{\{[^}]*\}\}\s*$/ },
2218
2224
  ];
2219
2225
 
2220
2226
  function scanIaC(fp, raw){
@@ -2227,6 +2233,7 @@ function scanIaC(fp, raw){
2227
2233
  let m;
2228
2234
  while ((m = re.exec(raw))) {
2229
2235
  const line = raw.substring(0, m.index).split('\n').length;
2236
+ if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
2230
2237
  findings.push({
2231
2238
  id: `iac:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 60)}`,
2232
2239
  kind: 'iac', severity: p.severity, vuln: p.vuln,
@@ -2747,10 +2754,24 @@ function scanLogicVulns(fp,raw){
2747
2754
  while((m=re.exec(haystack))){
2748
2755
  const line=lineAt(haystack,m.index);
2749
2756
  const snippet=lines[line-1]?.trim()||"";
2757
+ let outSnippet=snippet;
2750
2758
  // FP-2: credential FP filter
2751
2759
  if(pat.vuln==='Hardcoded Secret'||pat.vuln==='Hardcoded Credential Check'){
2752
2760
  const fpCheck=_isFalsePositiveCredential(fp,snippet,m[0]);
2753
2761
  if(fpCheck.skip){_suppressionLog.push({vuln:pat.vuln,file:fp,line,snippet,reason:fpCheck.reason});continue;}
2762
+ // Stage 4 correctness audit (coverage breadth, secrets): same
2763
+ // unredacted-snippet leak found in engine.js's scanEntropySecrets/
2764
+ // scanCredentials and sast/secret-concat.js — this is a THIRD,
2765
+ // separate detector (LOGIC_PATTERNS' own "Hardcoded Secret" rule)
2766
+ // that also stored the raw source line, with no masking at all.
2767
+ // The regex captures the quoted value inside `m[0]`; mask just
2768
+ // that value within the reported snippet.
2769
+ const valMatch=m[0].match(/['"]([^'"]{3,})['"]/);
2770
+ if(valMatch){
2771
+ const val=valMatch[1];
2772
+ const masked=val.length>8?val.substring(0,4)+"…"+val.substring(val.length-4):"••••";
2773
+ outSnippet=snippet.split(val).join(masked);
2774
+ }
2754
2775
  }
2755
2776
  // FP-6: operational-context gate for selected logic patterns
2756
2777
  if (predicate) {
@@ -2760,7 +2781,7 @@ function scanLogicVulns(fp,raw){
2760
2781
  continue;
2761
2782
  }
2762
2783
  }
2763
- results.push({vuln:pat.vuln,severity:pat.severity,cwe:pat.cwe,stride:pat.stride,kind:pat.kind,fix:pat.fix,code:pat.code,file:fp,line,snippet});
2784
+ results.push({vuln:pat.vuln,severity:pat.severity,cwe:pat.cwe,stride:pat.stride,kind:pat.kind,fix:pat.fix,code:pat.code,file:fp,line,snippet:outSnippet});
2764
2785
  }
2765
2786
  }
2766
2787
  const routeRe=/(?:app|router)\s*\.\s*(?:get|post|all)\s*\(\s*['"`](\/(?:debug|admin|test|internal|__)[^'"`]*)/gi;let rm;
@@ -5409,17 +5430,29 @@ function scanEntropySecrets(fp,raw){
5409
5430
  // FP-5: structural / doc-context suppression
5410
5431
  const surrounding=lines.slice(Math.max(0,line-3),Math.min(lines.length,line+1)).join("\n");
5411
5432
  const nonSecretReason=_isLikelyNonSecret(v, ctx, surrounding);
5433
+ const masked=v.substring(0,4)+"…"+v.substring(v.length-4);
5412
5434
  if (nonSecretReason) {
5413
- _suppressionLog.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim(),reason:'entropy-'+nonSecretReason});
5435
+ // Same redaction concern as the main finding below applies to the
5436
+ // suppression log — it's exposed via --include-suppressed, and a
5437
+ // heuristic "probably not a real secret" call can be wrong.
5438
+ _suppressionLog.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim().split(v).join(masked),reason:'entropy-'+nonSecretReason});
5414
5439
  continue;
5415
5440
  }
5416
- const masked=v.substring(0,4)+"…"+v.substring(v.length-4);
5441
+ // Stage 4 correctness audit (coverage breadth, secrets): `snippet` used
5442
+ // to carry the RAW, unmasked source line — including the full secret
5443
+ // value — even though `masked` right next to it was correctly
5444
+ // redacted. Nothing downstream (normalizeFindings, toHTML, toCSV,
5445
+ // toJUnit) ever redacts `snippet` again, so the plaintext credential
5446
+ // this detector exists to find flowed straight into every report
5447
+ // format this scanner emits, including last-scan.json. Redact the
5448
+ // exact matched value out of the snippet at the source, the same way
5449
+ // `masked` already is.
5417
5450
  out.push({
5418
5451
  vuln:"High-Entropy Credential Candidate",
5419
5452
  severity:"high",cwe:"CWE-798",stride:"Information Disclosure",
5420
5453
  fix:"Replace with environment variable or secrets manager reference; rotate the value immediately.",
5421
5454
  code:`// BEFORE\nconst secret = "${masked}";\n\n// AFTER\nconst secret = process.env.APP_SECRET;`,
5422
- file:fp,line,snippet:ctx.trim(),masked,entropy:e.toFixed(2)
5455
+ file:fp,line,snippet:ctx.trim().split(v).join(masked),masked,entropy:e.toFixed(2)
5423
5456
  });
5424
5457
  }
5425
5458
  return out;
@@ -5459,12 +5492,21 @@ function scanConfigFiles(fc){
5459
5492
  const[,k,v]=m;
5460
5493
  if(!v||v==='""'||v==="''"||/^(?:change.?me|your[_-]|placeholder|example|xxx+|todo|<|\$\{)/i.test(v))continue;
5461
5494
  if(/(?:password|secret|key|token|api)/i.test(k)){
5495
+ // Stage 6 correctness audit: this was the one secrets-adjacent
5496
+ // detector still shipping the RAW, unmasked source line as
5497
+ // `snippet` — its siblings (scanCredentials/scanEntropySecrets)
5498
+ // were already fixed for this exact leak in the Stage 4 audit.
5499
+ // Nothing downstream re-redacts `snippet`, and the MCP redact.js
5500
+ // catch-all requires a QUOTED value, which standard `.env`
5501
+ // KEY=value syntax never has — so the plaintext committed
5502
+ // secret flowed straight through explain_finding unredacted.
5503
+ const masked=v.length>8?v.substring(0,4)+"…"+v.substring(v.length-4):"…";
5462
5504
  out.push({
5463
5505
  vuln:`Committed .env with Real-Looking ${k}`,
5464
5506
  severity:"high",cwe:"CWE-538",stride:"Information Disclosure",
5465
5507
  fix:`Remove ${k} from committed env files. Use .env.example with placeholders and ignore .env in VCS.`,
5466
5508
  code:`# In .gitignore\n.env\n\n# .env.example (commit)\n${k}=<your-${k.toLowerCase()}>`,
5467
- file:fp,line:i+1,snippet:ln
5509
+ file:fp,line:i+1,snippet:ln.split(v).join(masked),masked
5468
5510
  });
5469
5511
  }
5470
5512
  }
@@ -6369,7 +6411,11 @@ function scanCredentials(fp,raw){
6369
6411
  if(seen.has(key))continue;seen.add(key);
6370
6412
  const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
6371
6413
  const masked=val.length>12?val.substring(0,6)+"••••••"+val.substring(val.length-4):val.substring(0,3)+"•••";
6372
- results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,snippet,masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
6414
+ // Stage 4 correctness audit (coverage breadth, secrets): same
6415
+ // unredacted-snippet leak as scanEntropySecrets — `snippet` carried
6416
+ // the raw source line (full credential value) straight through to
6417
+ // every report format. Redact the exact matched value here too.
6418
+ results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
6373
6419
  }
6374
6420
  }
6375
6421
  return results;
@@ -6534,7 +6580,7 @@ function _makePurl(ecosystem,name,version,group){
6534
6580
  const t={npm:'npm',pypi:'pypi'}[ecosystem]||ecosystem;
6535
6581
  if(!t)return'';
6536
6582
  const ns=group?`${encodeURIComponent(group)}/`:'';
6537
- return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+version:''}`;
6583
+ return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+encodeURIComponent(version):''}`;
6538
6584
  }
6539
6585
 
6540
6586
  function _parsePackageJson(text,filePath){
@@ -7508,7 +7554,7 @@ async function queryRegistries(components){
7508
7554
 
7509
7555
  // Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
7510
7556
  // fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
7511
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
7557
+ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
7512
7558
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
7513
7559
  // analysis can recognize calls to user-input-returning helper methods
7514
7560
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -7854,6 +7900,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7854
7900
  // R8 (PRD §5): OS packages from an extracted container image's package DBs
7855
7901
  // (dpkg/apk) — baked-in deps the Dockerfile never names. Feed the OSV/SBOM pipeline.
7856
7902
  try{const{extractImagePackages}=await import('./sca/image-packages.js');for(const ip of extractImagePackages(allFileContents)){if(!components.some(c=>c.ecosystem===ip.ecosystem&&c.name===ip.name&&c.version===ip.version))components.push(ip);}}catch(_){}
7903
+ // Dockerfile-declared apt/apk install-line packages — complementary to
7904
+ // extractImagePackages above (which reads an actually scanned filesystem's
7905
+ // installed-package DB, a different signal: build-time-declared vs.
7906
+ // actually-installed).
7907
+ try{const{extractContainerPackages}=await import('./sca/container.js');for(const cp of extractContainerPackages(allFileContents)){if(!components.some(c=>c.ecosystem===cp.ecosystem&&c.name===cp.name&&c.version===cp.version))components.push(cp);}}catch(_){}
7857
7908
  try{const{detectVendoredLibraries}=await import('./sca/vendor-detect.js');const vendored=detectVendoredLibraries(fc);for(const v of vendored){const key=`${v.ecosystem}:${v.name}:${v.version}`;if(!components.some(c=>`${c.ecosystem}:${c.name}:${c.version}`===key))components.push({...v,group:'',purl:`pkg:${v.ecosystem}/${v.name}@${v.version}`,filePath:v.file,isUnpinned:false,reachable:true});}}catch(_){}
7858
7909
  const reach=buildReachabilitySet(fc);
7859
7910
  const reachabilitySet=reach.imported;
@@ -8104,7 +8155,13 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8104
8155
  };
8105
8156
  _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
8106
8157
  _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
8107
- _runAnnotator("demoteUnreachable", () => { demoteUnreachable(finalFindings, { routes: aR }); });
8158
+ _runAnnotator("demoteUnreachable", () => {
8159
+ demoteUnreachable(finalFindings, { routes: aR });
8160
+ // `type: 'vulnerable_dep'` findings live in supplyChain, not finalFindings
8161
+ // (src/sca/CLAUDE.md) — demoteUnreachable's SCA-tier branch needs this
8162
+ // array passed explicitly or it never sees an SCA finding at all.
8163
+ demoteUnreachable(supplyChain, { routes: aR });
8164
+ });
8108
8165
  // Premortem #8: backfill parser/family BEFORE confidence and calibration,
8109
8166
  // because both consume those fields and silently no-op when they're null.
8110
8167
  _runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
@@ -8124,22 +8181,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8124
8181
  // Generalised sanitizer consumption (dataflow/sanitizer-gate.js): labels
8125
8182
  // findings whose flow passes a catalog sanitizer matching their family
8126
8183
  // (xss/url/cmd, not just sql) so the proof gate below can demote them the
8127
- // same way it demotes proven-clean SQL. `sanitizersOnPath` would need to
8128
- // be `{ [findingId]: string[] of sanitizer callees observed on that
8129
- // finding's flow }`. There is nothing to build that map from: the live
8130
- // taint walk in dataflow/engine.js does NOT consult sanitizer catalog
8131
- // entries at all. `matchSinkOrSanitizer()` returns every catalog hit for
8132
- // a callee, but every consumer in dataflow/*.js selects only
8133
- // `e.kind === 'sink'` there is no `'sanitizer'` branch anywhere in that
8134
- // tree. Taint is killed only by clean re-assignment of a variable
8135
- // (removePathAndDescendants, engine.js:374), which happens regardless of
8136
- // whether the RHS call is a catalog sanitizer.
8137
- // So this is `{}` and the gate below is INERT — not "awaiting plumbing"
8138
- // but awaiting the sanitizer walk itself. Making it live needs two things:
8139
- // (1) dataflow/engine.js honouring `kind === 'sanitizer'` at a call site,
8140
- // and (2) that call site's callee name threaded onto the finding
8141
- // alongside the trace/chain that proven-clean.js already reads.
8184
+ // same way it demotes proven-clean SQL.
8185
+ //
8186
+ // The taint walk now records the sanitizer callees observed on the value
8187
+ // reaching each sink argument (`dataflow/engine.js` `_sanitizersForExpr`)
8188
+ // and stamps them on the finding as `_sanitizersOnPath`. This rebuilds the
8189
+ // `{ [findingId]: string[] }` shape the gate wants. Both `id` and
8190
+ // `stableId` are keyed because the gate accepts either and stable ids are
8191
+ // assigned by an earlier annotator.
8192
+ //
8193
+ // The sanitizer never kills the taint in the walk itself: a mislabelled
8194
+ // sanitizer would then hide a real vulnerability outright, whereas a label
8195
+ // only demotes confidence here. Recall-preserving, on purpose.
8142
8196
  const sanitizersOnPath = {};
8197
+ for (const f of finalFindings) {
8198
+ const names = f && f._sanitizersOnPath;
8199
+ if (!Array.isArray(names) || !names.length) continue;
8200
+ if (f.id) sanitizersOnPath[f.id] = names;
8201
+ if (f.stableId) sanitizersOnPath[f.stableId] = names;
8202
+ }
8143
8203
  _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
8144
8204
  _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
8145
8205
  }
@@ -8288,8 +8348,10 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8288
8348
  // v3 next-gen: per-attacker-persona score matrix (FR-ADV-2). Must run AFTER
8289
8349
  // crown-jewels + mitigation composite so it sees those signals.
8290
8350
  _runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
8291
- // v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5).
8292
- _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(finalFindings, fc); });
8351
+ // v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5). Annotates
8352
+ // SCA findings (package-name-keyed) must run against supplyChain, not
8353
+ // finalFindings (SAST), which has no package-name field at all.
8354
+ _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(supplyChain, fc); });
8293
8355
  // v3 next-gen: bug-bounty payout prediction (FR-ADV-3). Composes with the
8294
8356
  // mitigation composite — gated/unreachable findings get the bounty scaled
8295
8357
  // down rather than zeroed.
@@ -8420,10 +8482,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8420
8482
  // gets strictly more wall-clock for the taint analysis itself than an
8421
8483
  // uninstrumented run with the same budget.
8422
8484
  let _sharedIR = null;
8485
+ // Java IR requires the ASYNC builder. `parser-java.js` exports an async
8486
+ // `parseJavaFile` (java-parser needs a dynamic import), so the sync
8487
+ // `buildProjectIR` has no Java branch at all — and both deep-path call sites
8488
+ // used it. The result was that no .java file had ever produced an IR function
8489
+ // in deep mode: `bench/layer-recall` measured java at 0/25 while the catalog
8490
+ // carried 7 Java sources and 15 Java sinks that had nothing to run against.
8491
+ // `buildProjectIRAsync` is a full mirror plus Java and had zero callers.
8492
+ //
8493
+ // Gated on the presence of .java rather than always awaiting: the async
8494
+ // builder is a superset, but switching every scan in the product to it to fix
8495
+ // one language would change the execution shape (and attempt the java-parser
8496
+ // import) for projects that contain no Java. `runFullScan` is already async,
8497
+ // so the await costs nothing structurally.
8498
+ const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
8499
+ const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
8423
8500
  const _irStatsTarget = irStatsTarget();
8424
8501
  if (_irStatsTarget) {
8425
8502
  try {
8426
- _sharedIR = buildProjectIR(fc);
8503
+ _sharedIR = await _buildIR();
8427
8504
  writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
8428
8505
  } catch (e) {
8429
8506
  // Instrumentation must never fail a scan. Surface only when debugging.
@@ -8432,16 +8509,27 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8432
8509
  }
8433
8510
  }
8434
8511
  }
8435
- const _deepRequested = process.env.AGENTIC_SECURITY_DEEP === '1';
8512
+ // `deep`/`deepInCi` come from runScan()'s options object (threaded through
8513
+ // unchanged from runScan.js) — an explicit-opt-in override alongside the
8514
+ // env vars, not a replacement for them. Added because `runScan(dir,
8515
+ // {deep:true})` was a silent, total no-op: this options object was
8516
+ // destructured for fileContents/depFileContents/scanRoot/resume only, so
8517
+ // `deep` was dropped on the floor and deep mode stayed off regardless.
8518
+ // Several interprocedural test files (interproc-k2.test.js,
8519
+ // parser-cs-kt.test.js, points-to.test.js) pass exactly this option
8520
+ // believing it enables deep mode — it never did, so those tests were
8521
+ // exercising whatever coincidentally fires without the deep engine, not
8522
+ // the interprocedural machinery they're named for.
8523
+ const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1';
8436
8524
  const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
8437
8525
  process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
8438
- const _deepInCiAllowed = process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8526
+ const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8439
8527
  const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
8440
8528
  if (_deepEnabled) {
8441
8529
  const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
8442
8530
  const t0 = Date.now();
8443
8531
  try {
8444
- const { perFile, callGraph } = _sharedIR || (_sharedIR = buildProjectIR(fc));
8532
+ const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
8445
8533
  // The runDeepAnalysis call is synchronous in this codebase; we can't
8446
8534
  // truly interrupt it without re-architecting the worklist. We pass a
8447
8535
  // deadlineMs hint that the inner loops check; if absent, we still cap
@@ -8471,6 +8559,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8471
8559
  f.validator_verdict = 'unvalidated';
8472
8560
  }
8473
8561
  finalFindings.push(...irFindings);
8562
+ // Sanitizer + proof gate, pass 2 of 2 — same ordering trap as the
8563
+ // ignore-pragma double pass below, and for the same reason: pass 1 runs
8564
+ // ~2300 lines above, long before deep-mode IR findings exist, so a
8565
+ // sanitized IR-TAINT flow was never labelled and a proven-clean one was
8566
+ // never demoted. Deep mode is what the CLI uses outside CI, so that was
8567
+ // the case that mattered most.
8568
+ //
8569
+ // Scoped to `irFindings` rather than re-running over `finalFindings`:
8570
+ // annotateProofGate demotes confidence, so a second pass over findings
8571
+ // pass 1 already handled would demote them twice.
8572
+ if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
8573
+ const _irSanitizers = {};
8574
+ for (const f of irFindings) {
8575
+ const names = f && f._sanitizersOnPath;
8576
+ if (!Array.isArray(names) || !names.length) continue;
8577
+ if (f.id) _irSanitizers[f.id] = names;
8578
+ if (f.stableId) _irSanitizers[f.stableId] = names;
8579
+ }
8580
+ _runAnnotator("applySanitizerGate:deep", () => {
8581
+ applySanitizerGate(irFindings, { sanitizersOnPath: _irSanitizers });
8582
+ });
8583
+ _runAnnotator("annotateProofGate:deep", () => { annotateProofGate(irFindings); });
8584
+ }
8474
8585
  // Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
8475
8586
  // findings land here, long after pass 1 ran, so without this an
8476
8587
  // `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
@@ -8526,11 +8637,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8526
8637
  confidence: 1.0,
8527
8638
  });
8528
8639
  }
8529
- // Phase 2 (Sentinel-parity): LLM validator stage. No-op unless the operator
8530
- // sets AGENTIC_SECURITY_LLM_VALIDATE=1 AND AGENTIC_SECURITY_LLM_ENDPOINT. When
8531
- // disabled, every finding gets unvalidated:true and the existing confidence
8532
- // pipeline accounts for that. When enabled, the validator emits accept/reject
8533
- // /escalate per finding; rejects are dropped into the suppression log.
8640
+ // Phase 2 (Sentinel-parity): LLM validator stage. DEFAULT-ON whenever
8641
+ // AGENTIC_SECURITY_LLM_ENDPOINT is configured not gated on
8642
+ // AGENTIC_SECURITY_LLM_VALIDATE=1 as this comment previously (and wrongly)
8643
+ // said. Opt OUT with AGENTIC_SECURITY_LLM_VALIDATE=0; the legacy
8644
+ // AGENTIC_SECURITY_LLM_VALIDATE=1 still works as an explicit-on no-op. With
8645
+ // no endpoint configured the validator stays a no-op regardless — no
8646
+ // surprise network calls from an unrelated env var. See
8647
+ // llm-validator/index.js's own header, which states this correctly; this
8648
+ // comment was the one that had drifted. When disabled, every finding gets
8649
+ // unvalidated:true and the existing confidence pipeline accounts for that.
8650
+ // When enabled, the validator emits accept/reject/escalate per finding;
8651
+ // rejects are dropped into the suppression log.
8534
8652
  try {
8535
8653
  // Concurrency defaults to 1 (the validator's deterministic-default).
8536
8654
  // Operators raise via AGENTIC_SECURITY_LLM_CONCURRENCY at the cost of
@@ -8543,7 +8661,23 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8543
8661
  vuln: d.vuln, file: d.file, line: d.line, snippet: d.snippet,
8544
8662
  reason: 'llm-validator:reject:' + (d.validator_reasoning || '').slice(0, 80),
8545
8663
  });
8664
+ // Re-run: annotateVerifierVerdicts ran (~8335) before validator_verdict
8665
+ // existed on any finding (set here, hundreds of lines later), so its
8666
+ // 'verified-by-llm' verdict — documented as one of five possible
8667
+ // outcomes in verifier.js's own header — could never be produced by the
8668
+ // real pipeline. Cheap and idempotent; re-running is the fix, not
8669
+ // moving the original call (confidence/exploitability annotators
8670
+ // upstream of it still need to run before validation, same as before).
8671
+ try { annotateVerifierVerdicts(finalFindings, { fileContents: fc }); } catch (_) {}
8546
8672
  } catch(_) {}
8673
+ // Same ordering fix as annotateVerifierVerdicts above: annotateConfidence
8674
+ // (~8111) computes f.confidence before f.unvalidated exists on any
8675
+ // finding, so its 0.85x "LLM validator unavailable" penalty could never
8676
+ // apply in the real pipeline. Runs unconditionally (outside the
8677
+ // llmValidateMany try-block above) because f.unvalidated is also set
8678
+ // directly on deep-mode IR findings appended earlier in this function,
8679
+ // independent of whether the LLM validator itself ran.
8680
+ try { applyUnvalidatedPenalty(finalFindings); } catch (_) {}
8547
8681
  try {
8548
8682
  const { kept, suppressed } = applyLearnedFeedback(scanRoot, finalFindings);
8549
8683
  finalFindings = kept;
@@ -8610,8 +8744,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8610
8744
  /(?:^|\/)test_[^/]*\.py$/i.test(f) || /_test\.(?:py|go)$/i.test(f));
8611
8745
  annotateScaVerdicts(supplyChain, { testsDetected: _testsDetected });
8612
8746
  } catch (_) {}
8613
- // 0.9.0 Feat-15: dep confusion
8614
- try{const dc=detectDepConfusion(annotatedComponents,scanRoot);aF.push(...dc);}catch(_){}
8747
+ // 0.9.0 Feat-15: dep confusion. These are supply-chain findings (kind:'sca',
8748
+ // derived from `components` not source code) and belong in supplyChain, not
8749
+ // aF — aF was already snapshotted into finalFindings at dedupeFindingsWithEvidence
8750
+ // above, so pushing into aF here silently discards every result.
8751
+ try{const dc=detectDepConfusion(annotatedComponents,scanRoot);supplyChain.push(...dc);}catch(_){}
8615
8752
  // Deployment-platform security checklist
8616
8753
  try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
8617
8754
  // Stack-specific security playbook
@@ -8814,8 +8951,22 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8814
8951
  if (process.env.AGENTIC_SECURITY_NO_COMPLIANCE !== '1') {
8815
8952
  try {
8816
8953
  const policy = loadCompliancePolicy(scanRoot);
8817
- if (policy && !policy._error) {
8954
+ if (policy && policy._error) {
8955
+ // CMP-5: a malformed policy used to be silently treated the same
8956
+ // as "no policy file" — the parse error (which names the exact
8957
+ // problem) was computed and then discarded. verifyPolicy now
8958
+ // reports it as a distinct 'error' status rather than staying
8959
+ // silent, and it is surfaced here too so an interactive scan
8960
+ // shows it.
8818
8961
  _complianceReport = verifyCompliancePolicy(policy, { scanRoot, findings: finalFindings });
8962
+ process.stderr.write(`[compliance] ${policy._error}\n`);
8963
+ } else if (policy) {
8964
+ // CMP-5: pass every channel a real scan produces, not just SAST —
8965
+ // a finding-family check for hardcoded-secret or vulnerable-dep
8966
+ // was previously invisible to secrets/SCA findings entirely.
8967
+ _complianceReport = verifyCompliancePolicy(policy, {
8968
+ scanRoot, findings: finalFindings, secrets: aSecrets, logicVulns: aLogic, supplyChain,
8969
+ });
8819
8970
  emitComplianceJsonLd(_complianceReport, scanRoot);
8820
8971
  emitComplianceMarkdown(_complianceReport, scanRoot);
8821
8972
  }
@@ -8895,7 +9046,16 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8895
9046
  let _analysisTier = null, _unmodeledSinks = null;
8896
9047
  try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
8897
9048
  try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
8898
- const _scanMeta={filesScanned:files.length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_fileTimings.sort((a,b)=>b.ms-a.ms).slice(0,20),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
9049
+ // filesScanned counts files actually analyzed (Object.keys(fc), the same
9050
+ // set computeAnalysisTiers above reads) — NOT files.length, the candidate
9051
+ // list before the per-file loop's size/density skips run. Using the
9052
+ // candidate count here double-counted skipped files: they were included
9053
+ // in filesScanned AND separately reported in filesSkipped/filesDenseSkipped,
9054
+ // so coverage-report.js's "scanned=N skipped=M" line implied N+M files were
9055
+ // seen when only N-of-those-candidates were actually analyzed.
9056
+ // checkpoint.total intentionally keeps files.length — that field means the
9057
+ // full candidate set for resume bookkeeping, a different, correct meaning.
9058
+ const _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_fileTimings.sort((a,b)=>b.ms-a.ms).slice(0,20),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
8899
9059
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
8900
9060
  // it. Anything that threw before this point leaves it in place to resume from.
8901
9061
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
package/src/ir/CLAUDE.md CHANGED
@@ -10,7 +10,10 @@ consumed by `scanner/src/dataflow/` for taint analysis.
10
10
  | JS / TS | `parser-js.js` | `@babel/parser` |
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
- | Java | `parser-java.js` | `java-parser` npm package (async) |
13
+ | 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
+ ⚠ 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`. **Params are still not extracted** (`params: []`, marked "deferred" in the source), so Java interprocedural summaries are limited. |
15
+ | 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). Measured as Ruby 0/20 IR-TAINT recall in `bench/layer-recall`. 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`. |
16
+ | C# | `parser-cs.js` | Hand-rolled. ⚠ `_lowerExpr`'s string-concat branch **must** guard on `_splitTopLevelPlus` returning more than one part — when the `+` is nested inside parens the splitter returns the input unchanged and the branch recurses on the identical string (stack overflow, swallowed by `buildProjectIR`'s per-file catch, surfacing only as "no IR"). `new Type(args)` is lowered to a call so taint reaches constructor sinks such as `new SqlCommand`. Guarded by `test/parser-cs-kt.test.js`. |
14
17
  | 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`. |
15
18
  | 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. |
16
19
 
@@ -0,0 +1,55 @@
1
+ // Shared by every hand-rolled regex IR parser (parser-cs.js, parser-go.js,
2
+ // parser-php.js, parser-rb.js) for extracting a call's callee + argument
3
+ // text without corruption when the call is followed by a chained member
4
+ // access (`Sanitize(x).Trim()`, `$obj->clean($x)->trim()`).
5
+ //
6
+ // The naive pattern every one of those files used —
7
+ // `/^(calleeRe)\s*\((.*)\)\s*$/s` — matches `(.*)` GREEDILY against the
8
+ // LAST `)` in the string, not the one balancing the FIRST `(`. For a
9
+ // chained call that swallows the chain's own parens into the argument
10
+ // text (`Sanitize(x).Trim()` produced argsText = "x).Trim("), which then
11
+ // fails to parse as any recognized expression shape in `_lowerExpr` and
12
+ // falls through to `{kind:'unknown'}` — silently losing whatever
13
+ // taint-relevant identifiers were inside the FIRST call's real argument
14
+ // list, exactly the shape a wrapped-and-then-methodcalled sanitizer or
15
+ // helper produces in real code.
16
+ //
17
+ // This scans forward from the first `(` tracking paren/bracket/brace
18
+ // depth and string-literal state (so a `)` or `,` inside a nested call or
19
+ // a string literal doesn't miscount) to find the REAL matching close
20
+ // paren. It deliberately does NOT require that close paren to be the end
21
+ // of the string — a trailing chain (`.Trim()`, `->trim()`, `::foo()`) is
22
+ // simply left unconsumed rather than corrupting anything. Recovering the
23
+ // first call's real signature is a strict improvement over the prior
24
+ // choice between silent corruption and no match at all.
25
+ export function matchBalancedCall(s, calleeRe) {
26
+ if (typeof s !== 'string') return null;
27
+ const m = calleeRe.exec(s);
28
+ if (!m || m.index !== 0) return null;
29
+ let i = m[0].length;
30
+ while (s[i] === ' ' || s[i] === '\t') i++;
31
+ if (s[i] !== '(') return null;
32
+ const openIdx = i;
33
+ let depth = 0;
34
+ let inStr = null;
35
+ let escape = false;
36
+ for (; i < s.length; i++) {
37
+ const c = s[i];
38
+ if (escape) { escape = false; continue; }
39
+ if (inStr) {
40
+ if (c === '\\') { escape = true; continue; }
41
+ if (c === inStr) inStr = null;
42
+ continue;
43
+ }
44
+ if (c === '"' || c === '\'') { inStr = c; continue; }
45
+ if (c === '(' || c === '[' || c === '{') { depth++; continue; }
46
+ if (c === ')' || c === ']' || c === '}') {
47
+ depth--;
48
+ if (depth === 0) break;
49
+ continue;
50
+ }
51
+ }
52
+ if (depth !== 0 || s[i] !== ')') return null; // unbalanced — refuse to guess
53
+ const callee = m[1] !== undefined ? m[1] : m[0];
54
+ return { callee, argsText: s.slice(openIdx + 1, i) };
55
+ }
@@ -27,6 +27,7 @@
27
27
 
28
28
  import * as crypto from 'node:crypto';
29
29
  import { callSitesFromCfg } from './call-sites.js';
30
+ import { matchBalancedCall } from './balanced-call.js';
30
31
 
31
32
  const METHOD_RE = new RegExp(
32
33
  '(?:^|[\\s;{}])(?:public|private|protected|internal|static|virtual|override|async|sealed|abstract|new|readonly|partial)' +
@@ -80,17 +81,69 @@ function _lowerExpr(text) {
80
81
  if (parts.length === 1) return { kind: 'ident', name: parts[0] };
81
82
  return _buildMemberChain(parts);
82
83
  }
83
- // Call: foo.bar(args) or Bar(args). Find the LAST '(' at depth 0.
84
- const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
84
+ // Object creation: `new Type(args)` lowered to a call so taint flows into
85
+ // constructor arguments. Without this branch the expression fell through to
86
+ // the concat heuristic below, and because the `+` sits INSIDE the parens
87
+ // `_splitTopLevelPlus` returned the input unchanged, so that branch recursed
88
+ // on the identical string until the stack blew. `buildProjectIR` catches
89
+ // per-file, so the crash surfaced only as "this file has no IR" — 12 of 21 C#
90
+ // corpus entries, and the catalog's own `cs-sqlcommand` rule ("SQL Injection
91
+ // (new SqlCommand with concatenated user input)") could never fire.
92
+ const newMatch = matchBalancedCall(s, /^new\s+([\w.]+)/);
93
+ if (newMatch) {
94
+ const callee = newMatch.callee.split('.').pop();
95
+ const args = _splitTopLevelCommas(newMatch.argsText).map(_lowerExpr);
96
+ return { kind: 'call', callee, args, isNew: true };
97
+ }
98
+ // Call: foo.bar(args) or Bar(args). matchBalancedCall finds the paren
99
+ // that actually balances the FIRST '(' — not the greedy-to-end-of-string
100
+ // match the old `/\((.*)\)\s*$/` used, which corrupted the argument text
101
+ // for a chained call (`Sanitize(x).Trim()` produced argsText="x).Trim(",
102
+ // which then fell through to {kind:'unknown'} and silently dropped x).
103
+ const callMatch = matchBalancedCall(s, /^([\w.]+)/);
85
104
  if (callMatch) {
86
- const callee = callMatch[1];
87
- const args = _splitTopLevelCommas(callMatch[2]).map(_lowerExpr);
88
- return { kind: 'call', callee, args };
105
+ const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
106
+ return { kind: 'call', callee: callMatch.callee, args };
89
107
  }
90
108
  // String concat / interpolation — heuristic.
109
+ //
110
+ // The `parts.length > 1` guard is load-bearing, not defensive tidiness: when
111
+ // the `+` is nested inside parens or brackets, `_splitTopLevelPlus` returns
112
+ // the input as a single part, and mapping `_lowerExpr` over it recurses on the
113
+ // identical string forever. Any future expression form that reaches here
114
+ // unsplit would otherwise reintroduce the same stack overflow.
91
115
  if (s.includes('+') && /["']/.test(s)) {
92
- const parts = _splitTopLevelPlus(s).map(_lowerExpr);
93
- return { kind: 'tpl', parts };
116
+ const rawParts = _splitTopLevelPlus(s);
117
+ if (rawParts.length > 1) return { kind: 'tpl', parts: rawParts.map(_lowerExpr) };
118
+ }
119
+ // Stage 3 correctness audit (detection depth, per-language-IR):
120
+ // interpolated strings ($"id={id}", $@"...", @$"...") were entirely
121
+ // unrecognized by every branch above — not even treated as an opaque
122
+ // literal, since nothing here tested for the leading `$` prefix — so
123
+ // they fell all the way through to {kind:'unknown'}, silently dropping
124
+ // any interpolated variable's taint. This is exactly
125
+ // `new SqlCommand($"SELECT ... WHERE id={id}", conn)`, one of the most
126
+ // common real C# SQL-injection shapes. `{expr}` / `{expr:format}` are
127
+ // lowered into a template, same shape as the `+`-concat branch above.
128
+ if (/^\$@?"/.test(s) || /^@\$"/.test(s)) {
129
+ const bodyStart = s.indexOf('"') + 1;
130
+ const inner = s.slice(bodyStart, -1);
131
+ const re = /\{([^{}:]+)(?::[^{}]*)?\}/g;
132
+ let lastIndex = 0;
133
+ const parts = [];
134
+ let matched = false;
135
+ let m;
136
+ while ((m = re.exec(inner)) !== null) {
137
+ matched = true;
138
+ if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
139
+ parts.push(_lowerExpr(m[1].trim()));
140
+ lastIndex = re.lastIndex;
141
+ }
142
+ if (matched) {
143
+ if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
144
+ return { kind: 'tpl', parts };
145
+ }
146
+ return { kind: 'literal', value: s };
94
147
  }
95
148
  if (/^"|^@"/.test(s)) return { kind: 'literal', value: s };
96
149
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
@@ -169,9 +222,9 @@ function _lowerStmt(stmt, line) {
169
222
  return { kind: 'assign', line, target, source: _lowerExpr(sourceText) };
170
223
  }
171
224
  // statement-form call
172
- const cm = s.match(/^([A-Za-z_][\w.]*)\s*\((.*)\)\s*$/s);
225
+ const cm = matchBalancedCall(s, /^([A-Za-z_][\w.]*)/);
173
226
  if (cm) {
174
- return { kind: 'call', line, callee: cm[1], args: _splitTopLevelCommas(cm[2]).map(_lowerExpr) };
227
+ return { kind: 'call', line, callee: cm.callee, args: _splitTopLevelCommas(cm.argsText).map(_lowerExpr) };
175
228
  }
176
229
  return { kind: 'unknown', line, text: s };
177
230
  }