@clear-capabilities/agentic-security-scanner 0.128.1 → 0.130.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 (79) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +209 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +2 -2
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/830.index.js +1 -1
  13. package/dist/agentic-security.mjs +113 -162
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +22 -14
  16. package/src/dataflow/CLAUDE.md +4 -1
  17. package/src/dataflow/async-sequencing.js +8 -3
  18. package/src/dataflow/catalog.js +278 -11
  19. package/src/dataflow/cross-repo.js +1 -1
  20. package/src/dataflow/cross-service-taint.js +1 -1
  21. package/src/dataflow/engine.js +182 -61
  22. package/src/dataflow/ifds.js +10 -5
  23. package/src/dataflow/index.js +15 -3
  24. package/src/dataflow/points-to.js +8 -2
  25. package/src/dataflow/proof-gate.js +7 -0
  26. package/src/dataflow/sanitizer-gate.js +89 -0
  27. package/src/dataflow/tabulation.js +14 -3
  28. package/src/engine.js +154 -7
  29. package/src/integrations/index.js +1 -1
  30. package/src/ir/CLAUDE.md +49 -4
  31. package/src/ir/call-sites.js +66 -0
  32. package/src/ir/callgraph.js +174 -7
  33. package/src/ir/class-hierarchy.js +22 -2
  34. package/src/ir/index.js +138 -51
  35. package/src/ir/ir-stats.js +126 -0
  36. package/src/ir/parser-cpp.js +829 -0
  37. package/src/ir/parser-cs.js +4 -1
  38. package/src/ir/parser-go.js +4 -1
  39. package/src/ir/parser-js.js +5 -1
  40. package/src/ir/parser-kt.js +4 -1
  41. package/src/ir/parser-php.js +10 -3
  42. package/src/ir/parser-py-cst.js +62 -10
  43. package/src/ir/tree-sitter-loader.js +13 -1
  44. package/src/llm-validator/index.js +9 -2
  45. package/src/llm-validator/redact.js +157 -0
  46. package/src/posture/CLAUDE.md +115 -0
  47. package/src/posture/accuracy-scorecard.js +317 -0
  48. package/src/posture/api-contract.js +1 -1
  49. package/src/posture/attestation.js +199 -0
  50. package/src/posture/auditor-walkthrough.js +12 -3
  51. package/src/posture/compliance-policy.js +1 -1
  52. package/src/posture/cross-lang-openapi.js +1 -1
  53. package/src/posture/custom-rules.js +1 -1
  54. package/src/posture/execution-proof.js +52 -0
  55. package/src/posture/exploitability-probability.js +1 -1
  56. package/src/posture/falsification.js +45 -1
  57. package/src/posture/fix-verify.js +55 -2
  58. package/src/posture/license-policy.js +1 -1
  59. package/src/posture/profile.js +1 -1
  60. package/src/posture/proof-tier.js +33 -0
  61. package/src/posture/relevance.js +379 -0
  62. package/src/posture/rule-overrides.js +1 -1
  63. package/src/posture/sca-policy.js +1 -1
  64. package/src/posture/scan-checkpoint.js +277 -0
  65. package/src/posture/suppressions.js +1 -1
  66. package/src/posture/test-runner.js +147 -0
  67. package/src/posture/verification-separation.js +131 -0
  68. package/src/report/index.js +11 -0
  69. package/src/runScan.js +3 -1
  70. package/src/sandbox/CLAUDE.md +218 -0
  71. package/src/sandbox/backend-disabled.js +14 -0
  72. package/src/sandbox/backend-namespace.js +83 -0
  73. package/src/sandbox/backend-userspace.js +100 -0
  74. package/src/sandbox/capabilities.js +53 -0
  75. package/src/sandbox/index.js +30 -0
  76. package/src/sandbox/limits.js +42 -0
  77. package/src/sandbox/result.js +104 -0
  78. package/src/sca/dep-confusion.js +1 -1
  79. package/src/util/yaml.js +24 -0
package/src/engine.js CHANGED
@@ -6,7 +6,7 @@ import * as fs from 'node:fs';
6
6
  import * as path from 'node:path';
7
7
  import * as os from 'node:os';
8
8
  import * as crypto from 'node:crypto';
9
- import * as yaml from 'js-yaml';
9
+ import * as yaml from './util/yaml.js';
10
10
  import { createRequire } from 'node:module';
11
11
  const _require = createRequire(import.meta.url);
12
12
  import { scanLLM } from './sast/llm.js';
@@ -143,6 +143,7 @@ import { annotateNarration } from './posture/flow-narration.js';
143
143
  import { applyPathConstraints } from './posture/path-predicates.js';
144
144
  // Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
145
145
  import { buildProjectIR } from './ir/index.js';
146
+ import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
146
147
  import { runDeepAnalysis } from './dataflow/index.js';
147
148
  // v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
148
149
  import { annotateCloneClusters, findCloneOutliers } from './posture/semantic-clone.js';
@@ -174,9 +175,11 @@ import { applyLearnedCalibration } from './posture/triage-learning.js';
174
175
  import { annotateFormalVerification } from './dataflow/formal-verify.js';
175
176
  import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
176
177
  import { annotateProofGate } from './dataflow/proof-gate.js';
178
+ import { applySanitizerGate } from './dataflow/sanitizer-gate.js';
177
179
  import { annotateFalsification } from './posture/falsification.js';
178
180
  import { routeModelForFinding } from './posture/model-routing.js';
179
181
  import { buildEntrypointInventory } from './posture/entrypoint-inventory.js';
182
+ import { annotateRelevance } from './posture/relevance.js';
180
183
  import { sweepRootCauses } from './posture/root-cause-sweep.js';
181
184
  import { computeAnalysisTiers, countUnmodeledSinkCandidates } from './posture/coverage-report.js';
182
185
  import { annotatePrivacyTaint, emitDpiaArtifact } from './dataflow/privacy-taint.js';
@@ -212,6 +215,13 @@ import { buildTrustBoundaryDiagram } from './posture/trust-boundary-diagram.js';
212
215
  import { scanConcurrency } from './posture/concurrency-checker.js';
213
216
  import { annotateBountyPrediction } from './posture/bounty-prediction.js';
214
217
  import { annotateAttackPlaybooks } from './posture/attack-playbooks.js';
218
+ // R8: opt-in scan checkpointing/resume for the per-file loop.
219
+ import {
220
+ openCheckpoint, recordFileDone, completedFiles, resumeFindings, closeCheckpoint,
221
+ computeRunKey, bundleShaForRunKey,
222
+ } from './posture/scan-checkpoint.js';
223
+ import { SCANNER_VERSION as _ENGINE_VERSION } from './posture/version.js';
224
+ import { effectiveVersion as _effectiveRulesetVersion } from './posture/ruleset-version.js';
215
225
 
216
226
  // Disk-backed cache replacing browser sessionStorage. One JSON blob per key under ~/.claude/agentic-security/osv-cache/.
217
227
  const _CACHE_DIR = path.join(os.homedir(), '.claude', 'agentic-security', 'osv-cache');
@@ -985,7 +995,7 @@ function performASTAnalysis(fp, code) {
985
995
  try {
986
996
  babelTransformSync(code, {
987
997
  filename: fp,
988
- presets: [presetReact, [presetTypescript, { isTSX: true, allExtensions: true }]],
998
+ presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
989
999
  plugins: [astTaintTrackerPlugin],
990
1000
  ast: false, code: false,
991
1001
  babelrc: false, configFile: false,
@@ -2266,6 +2276,10 @@ function _isFalsePositiveCredential(fp, snippet, fullMatch){
2266
2276
  // Module-level suppression log; cleared at the start of each runFullScan invocation.
2267
2277
  const _suppressionLog = [];
2268
2278
  function _resetSuppressions(){ _suppressionLog.length = 0; }
2279
+ // R8: the per-file taint result minus the four arrays that are also appended
2280
+ // wholesale to the aggregates. Resume rebuilds those from the aggregate slices
2281
+ // so pfr[p] and the aggregates share object identity, exactly as in a normal run.
2282
+ function _pfrMetaOnly(ta){ if(!ta||typeof ta!=='object')return {}; const o={}; for(const k of Object.keys(ta)){ if(k==='findings'||k==='sources'||k==='sinks'||k==='sanitizers')continue; o[k]=ta[k]; } return o; }
2269
2283
  function _getSuppressions(){ return [..._suppressionLog]; }
2270
2284
 
2271
2285
  // FP-9 / Feat-4: custom rules loaded from .agentic-security/rules.{yml,yaml,json}
@@ -4631,7 +4645,7 @@ function _buildCallGraphAST(fp, code){
4631
4645
 
4632
4646
  babelTransformSync(code, {
4633
4647
  filename: fp,
4634
- presets: [presetReact, [presetTypescript, { isTSX: true, allExtensions: true }]],
4648
+ presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
4635
4649
  plugins: [callTrackerPlugin],
4636
4650
  ast: false, code: false,
4637
4651
  babelrc: false, configFile: false,
@@ -7389,7 +7403,7 @@ async function queryRegistries(components){
7389
7403
 
7390
7404
  // Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
7391
7405
  // fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
7392
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
7406
+ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
7393
7407
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
7394
7408
  // analysis can recognize calls to user-input-returning helper methods
7395
7409
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -7399,7 +7413,75 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7399
7413
  const _perFileTimeoutMs = parseInt(process.env.AGENTIC_SECURITY_PER_FILE_TIMEOUT_MS || '10000', 10);
7400
7414
  const _fileTimings = [];
7401
7415
  let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
7402
- const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}fc[p]=c;aR.push(...scanRoutes(p,c));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,c));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));aF.push(...scanTerraform(p,c));
7416
+ const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
7417
+ // ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
7418
+ // runScan({resume:true})). Default OFF, so existing behaviour is untouched.
7419
+ // Only this loop is checkpointed; every cross-file pass below re-runs, so
7420
+ // nothing that depends on the whole tree can be stale on resume.
7421
+ const _ckptEnabled = (resume === undefined ? process.env.AGENTIC_SECURITY_RESUME === '1' : !!resume) && !!scanRoot;
7422
+ let _ckpt = null; const _ckptPayloads = new Map(); let _ckptDone = new Set(); let _ckptResumed = 0, _ckptWrites = 0;
7423
+ const _ckptAbortAfter = parseInt(process.env.AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER || '0', 10) || 0;
7424
+ if (_ckptEnabled) {
7425
+ try {
7426
+ const _runKey = computeRunKey({
7427
+ engineVersion: _ENGINE_VERSION,
7428
+ rulesetVersion: (_effectiveRulesetVersion(scanRoot) || {}).version,
7429
+ bundleSha: bundleShaForRunKey(),
7430
+ fileContents, depFileContents,
7431
+ });
7432
+ _ckpt = openCheckpoint(scanRoot, { runKey: _runKey });
7433
+ for (const r of resumeFindings(_ckpt)) { if (r && r.findings) _ckptPayloads.set(r.file, r.findings); }
7434
+ _ckptDone = completedFiles(_ckpt);
7435
+ } catch (_) { _ckpt = null; }
7436
+ }
7437
+ // Replay a checkpointed file's ENTIRE contribution, in the same array order
7438
+ // and with the same object identities between pfr[p] and the aggregate arrays
7439
+ // that an uninterrupted run would have produced.
7440
+ const _ckptReplay = (p) => {
7441
+ const d = _ckptPayloads.get(p);
7442
+ if (!d) return false;
7443
+ const c = fileContents[p]; if (!c) return false;
7444
+ fc[p] = c;
7445
+ const f0 = aF.length, s0 = aSrc.length, k0 = aSink.length, n0 = aSan.length;
7446
+ for (const x of (d.findings || [])) aF.push(x);
7447
+ for (const x of (d.routes || [])) aR.push(x);
7448
+ for (const x of (d.sources || [])) aSrc.push(x);
7449
+ for (const x of (d.sinks || [])) aSink.push(x);
7450
+ for (const x of (d.sanitizers || [])) aSan.push(x);
7451
+ for (const x of (d.logic || [])) aLogic.push(x);
7452
+ for (const x of (d.secrets || [])) aSecrets.push(x);
7453
+ for (const x of (d.ciphersRest || [])) aCiphersRest.push(x);
7454
+ for (const x of (d.ciphersTransit || [])) aCiphersTransit.push(x);
7455
+ for (const x of (d.suppressions || [])) _suppressionLog.push(x);
7456
+ const ta = Object.assign({}, d.pfr || {});
7457
+ ta.findings = aF.slice(f0, f0 + (d.pfrFindings || 0));
7458
+ ta.sources = aSrc.slice(s0); ta.sinks = aSink.slice(k0); ta.sanitizers = aSan.slice(n0);
7459
+ pfr[p] = ta;
7460
+ _fileTimings.push({ file: p, ms: d.ms || 0 });
7461
+ _ckptResumed++;
7462
+ return true;
7463
+ };
7464
+ const _ckptRecord = (p, mk, ms, ta) => {
7465
+ if (!_ckpt || !_ckpt.enabled) return;
7466
+ recordFileDone(_ckpt, p, {
7467
+ routes: aR.slice(mk.aR), findings: aF.slice(mk.aF),
7468
+ sources: aSrc.slice(mk.aSrc), sinks: aSink.slice(mk.aSink), sanitizers: aSan.slice(mk.aSan),
7469
+ logic: aLogic.slice(mk.aLogic), secrets: aSecrets.slice(mk.aSecrets),
7470
+ ciphersRest: aCiphersRest.slice(mk.aCR), ciphersTransit: aCiphersTransit.slice(mk.aCT),
7471
+ suppressions: _suppressionLog.slice(mk.sup),
7472
+ pfr: _pfrMetaOnly(ta),
7473
+ pfrFindings: (ta && Array.isArray(ta.findings)) ? ta.findings.length : 0,
7474
+ ms,
7475
+ });
7476
+ _ckptWrites++;
7477
+ // Fault injection for the resume test: a hard exit with no unwinding, which
7478
+ // is what the checkpoint format has to survive. Never set in normal use.
7479
+ if (_ckptAbortAfter > 0 && _ckptWrites >= _ckptAbortAfter) process.exit(137);
7480
+ };
7481
+ let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
7482
+ if(_ckptDone.has(p)&&_ckptReplay(p))continue;
7483
+ const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
7484
+ try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}fc[p]=c;aR.push(...scanRoutes(p,c));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,c));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));aF.push(...scanTerraform(p,c));
7403
7485
  aF.push(...scanLLM(p,c));
7404
7486
  aF.push(...scanLLMOwasp(p,c));
7405
7487
  aF.push(...scanLlmCost(p,c));
@@ -7504,6 +7586,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7504
7586
  const _ftElapsed=Date.now()-_ft0;
7505
7587
  if(_ftElapsed>_perFileTimeoutMs){aF.push({id:`file-timeout:${p}`,file:p,line:0,vuln:`File analysis exceeded ${_perFileTimeoutMs}ms (${_ftElapsed}ms)`,severity:'info',parser:'ENGINE',confidence:0.5,_timeout:true});_filesTimedOut++;}
7506
7588
  _fileTimings.push({file:p,ms:_ftElapsed});
7589
+ _ckptRecord(p,_mk,_ftElapsed,ta);
7507
7590
  }catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}if(i%5===0)await new Promise(r=>setTimeout(r,0));}
7508
7591
  // Deserialization-gadget detector runs once with full-tree context (it needs
7509
7592
  // manifest contents to know which gadget libs are on the classpath).
@@ -7914,6 +7997,26 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7914
7997
  // on; opt out with AGENTIC_SECURITY_NO_PROOF_GATE=1. Demotes proven-clean /
7915
7998
  // proven-infeasible flows (confidence + tiers only, never severity).
7916
7999
  if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
8000
+ // Generalised sanitizer consumption (dataflow/sanitizer-gate.js): labels
8001
+ // findings whose flow passes a catalog sanitizer matching their family
8002
+ // (xss/url/cmd, not just sql) so the proof gate below can demote them the
8003
+ // same way it demotes proven-clean SQL. `sanitizersOnPath` would need to
8004
+ // be `{ [findingId]: string[] of sanitizer callees observed on that
8005
+ // finding's flow }`. There is nothing to build that map from: the live
8006
+ // taint walk in dataflow/engine.js does NOT consult sanitizer catalog
8007
+ // entries at all. `matchSinkOrSanitizer()` returns every catalog hit for
8008
+ // a callee, but every consumer in dataflow/*.js selects only
8009
+ // `e.kind === 'sink'` — there is no `'sanitizer'` branch anywhere in that
8010
+ // tree. Taint is killed only by clean re-assignment of a variable
8011
+ // (removePathAndDescendants, engine.js:374), which happens regardless of
8012
+ // whether the RHS call is a catalog sanitizer.
8013
+ // So this is `{}` and the gate below is INERT — not "awaiting plumbing"
8014
+ // but awaiting the sanitizer walk itself. Making it live needs two things:
8015
+ // (1) dataflow/engine.js honouring `kind === 'sanitizer'` at a call site,
8016
+ // and (2) that call site's callee name threaded onto the finding
8017
+ // alongside the trace/chain that proven-clean.js already reads.
8018
+ const sanitizersOnPath = {};
8019
+ _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
7917
8020
  _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
7918
8021
  }
7919
8022
  // Addition #1 — default falsification pass. Actively tries to DISPROVE each
@@ -8156,6 +8259,33 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
8156
8259
  // - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
8157
8260
  // - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
8158
8261
  // so a pathological file can't hang the whole pipeline.
8262
+ // ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
8263
+ // Built ahead of the deep-mode gate so coverage is measurable without paying
8264
+ // for taint analysis, and stashed in _sharedIR so the deep block below reuses
8265
+ // it rather than parsing the project twice.
8266
+ //
8267
+ // NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
8268
+ // when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
8269
+ // budget timer (t0) below is started. On an uninstrumented run, IR
8270
+ // construction instead happens inside the timed block via the
8271
+ // `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
8272
+ // against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
8273
+ // NOT account for parse time when stats are enabled — an instrumented run
8274
+ // gets strictly more wall-clock for the taint analysis itself than an
8275
+ // uninstrumented run with the same budget.
8276
+ let _sharedIR = null;
8277
+ const _irStatsTarget = irStatsTarget();
8278
+ if (_irStatsTarget) {
8279
+ try {
8280
+ _sharedIR = buildProjectIR(fc);
8281
+ writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
8282
+ } catch (e) {
8283
+ // Instrumentation must never fail a scan. Surface only when debugging.
8284
+ if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
8285
+ process.stderr.write(`ir-stats: ${e && e.message}\n`);
8286
+ }
8287
+ }
8288
+ }
8159
8289
  const _deepRequested = process.env.AGENTIC_SECURITY_DEEP === '1';
8160
8290
  const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
8161
8291
  process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
@@ -8165,7 +8295,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
8165
8295
  const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
8166
8296
  const t0 = Date.now();
8167
8297
  try {
8168
- const { perFile, callGraph } = buildProjectIR(fc);
8298
+ const { perFile, callGraph } = _sharedIR || (_sharedIR = buildProjectIR(fc));
8169
8299
  // The runDeepAnalysis call is synchronous in this codebase; we can't
8170
8300
  // truly interrupt it without re-architecting the worklist. We pass a
8171
8301
  // deadlineMs hint that the inner loops check; if absent, we still cap
@@ -8562,9 +8692,26 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
8562
8692
  let _analysisTier = null, _unmodeledSinks = null;
8563
8693
  try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
8564
8694
  try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
8565
- 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}};
8695
+ 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}};
8696
+ // R8: the scan completed, so the checkpoint has been fully consumed — remove
8697
+ // it. Anything that threw before this point leaves it in place to resume from.
8698
+ try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
8566
8699
  // Addition #2 — attack-surface completeness inventory (entry points → dispositions).
8567
8700
  let _entrypointInventory = {}; try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
8701
+ // R9 + R6 — relevance scoping. Runs HERE, after every finding has been
8702
+ // appended (multi-sink chains, cross-language chains) and after the
8703
+ // entry-point inventory exists, so no finding escapes annotation and the
8704
+ // attack surface it is scored against is the complete one. Recall-
8705
+ // preserving: never removes a finding, never touches severity, and only
8706
+ // asserts `unreachable` on positive evidence (see posture/relevance.js).
8707
+ _runAnnotator("annotateRelevance", () => {
8708
+ annotateRelevance(finalFindings, {
8709
+ fileContents: fc,
8710
+ entrypointInventory: _entrypointInventory,
8711
+ routes: aR,
8712
+ threatModel: _v3 && _v3.threatModel,
8713
+ });
8714
+ });
8568
8715
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
8569
8716
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
8570
8717
  let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
@@ -12,7 +12,7 @@
12
12
 
13
13
  import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
- import * as yaml from 'js-yaml';
15
+ import * as yaml from '../util/yaml.js';
16
16
  import { statePath } from '../posture/state-dir.js';
17
17
 
18
18
  function _configPath(scanRoot) {
package/src/ir/CLAUDE.md CHANGED
@@ -11,7 +11,8 @@ consumed by `scanner/src/dataflow/` for taint analysis.
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
13
  | Java | `parser-java.js` | `java-parser` npm package (async) |
14
- | Long-tail (rust/solidity/cpp/c/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. |
14
+ | 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
+ | 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. |
15
16
 
16
17
  ## Python parser — dual-path with auto fallback
17
18
 
@@ -42,8 +43,9 @@ every dropped function.
42
43
  The CST parser (`parser-py-cst.js`) shells out to a small Python helper
43
44
  script (`parser-py.helper.py`) that uses the stdlib `ast` module — zero
44
45
  external dependencies, ships with every Python 3.8+ install. The helper
45
- emits the same IR shape (`{functions[{qid,name,line,params,cfg,file}],
46
- topLevel}`) as the regex parser. The CFG is built from the real AST, so:
46
+ emits the same core IR shape (`{functions[{qid,name,line,params,cfg,file}],
47
+ topLevel}`) as the regex parser, **with one exception below.** The CFG is
48
+ built from the real AST, so:
47
49
 
48
50
  - decorators don't drop the function record
49
51
  - async def is recognized
@@ -53,6 +55,16 @@ topLevel}`) as the regex parser. The CFG is built from the real AST, so:
53
55
  `[x for x in untrusted]`
54
56
  - nested function defs become separate entries in `functions[]`
55
57
  - `def f(x=Foo(1,2))` and `db.execute(sanitize(x))` parse correctly
58
+ - **`fn.calls` (CST path only)** — `parser-py-cst.js` derives
59
+ `calls: [{site, callee, args, line}]` from the CFG after parsing (statement-
60
+ position calls, plus calls embedded in an assign's RHS, a return/throw
61
+ value, or an if condition). **The regex parser (`parser-py.js`) does not
62
+ emit `fn.calls` at all.** In practice: a scan that falls back to the regex
63
+ parser (no `python3` on PATH, too old, or the helper failing) still
64
+ produces findings, but Python loses cross-function (interprocedural) taint
65
+ tracking for that run — `tabulation.js`, `dataflow/index.js` and
66
+ `callgraph.js` all read `fn.calls`, and an absent/empty array there means a
67
+ function's call sites are invisible to them, same as if it called nothing.
56
68
 
57
69
  ### Cost
58
70
 
@@ -70,12 +82,41 @@ topLevel}`) as the regex parser. The CFG is built from the real AST, so:
70
82
  - `python3` / `python` not on PATH
71
83
  - Python version < 3.8
72
84
  - helper script's stdin JSON corruption
73
- - helper subprocess timeout (10 s for the whole batch — generous)
85
+ - helper subprocess timeout (30 s for the whole batch — generous;
86
+ `AGENTIC_SECURITY_PY_BATCH_TIMEOUT_MS`)
87
+ - capability probe timeout (5 s; `AGENTIC_SECURITY_PY_PROBE_TIMEOUT_MS`)
74
88
  - helper output isn't parseable JSON
75
89
 
76
90
  Each of these is a real failure mode; the regex fallback keeps the scan
77
91
  producing findings instead of returning empty.
78
92
 
93
+ ### Fallbacks are recorded, not silent
94
+
95
+ Every fallback above calls `noteParserDegradation(reason)`. Read it with
96
+ `pythonParserDegradation()` and clear it with `resetPythonParserDegradation()`
97
+ (both re-exported from `./index.js`).
98
+
99
+ This exists because a silent fallback is indistinguishable from a detection
100
+ regression: no `fn.calls` means no interprocedural Python taint, so a test
101
+ that *depends* on interprocedural analysis just stops finding anything. The
102
+ CVE-replay corpus gate (`bench/cve-replay/runner.mjs`) resets the record
103
+ before each deep Python entry and checks it after, reporting `env-error` and
104
+ exiting 3 instead of scoring a phantom `pre:FN`. It had already produced a
105
+ real flake: exit 1 `REGRESSED` then exit 0 on the same clean tree, minutes
106
+ apart, purely from machine load.
107
+
108
+ Two hardening changes back this:
109
+ - The probe timeout is **not cached**. `spawnSync` reports a timeout via
110
+ `r.error.code === 'ETIMEDOUT'` with a null status — that is a load symptom,
111
+ not "python is missing", so it does not poison `_capability` for the rest
112
+ of the process. Only a genuine `no-python3-on-path` is cached.
113
+ - Budgets were raised (1.5 s → 5 s probe, 10 s → 30 s batch) and made
114
+ env-tunable for constrained runners.
115
+
116
+ If you add a new caller that requires the CST path, check
117
+ `pythonParserDegradation()` rather than assuming the parse you got is the
118
+ parse you asked for.
119
+
79
120
  ### What CST models (and the one remaining limit)
80
121
 
81
122
  The helper now lowers — and the dataflow engine propagates taint through — all
@@ -176,3 +217,7 @@ down enterprise runners sometimes don't. Targets for retirement:
176
217
  via the optional telemetry surface.
177
218
  - Or, the `AGENTIC_SECURITY_PY_PARSER` env defaults to `cst` (strict
178
219
  mode) for one release with no customer complaint tickets filed.
220
+ - The `fn.calls` gap above is a concrete, ongoing argument for retirement:
221
+ every fallback to the regex parser is now also a silent loss of Python
222
+ interprocedural analysis, not just the older, vaguer "some constructs get
223
+ dropped" cost.
@@ -0,0 +1,66 @@
1
+ // Shared call-site extraction.
2
+ //
3
+ // Reads only the IR contract documented in ./CLAUDE.md — it walks cfg.nodes and
4
+ // collects call expressions from `call`, `assign`, `return`, `throw` and `if`
5
+ // nodes — so nothing here is language-specific.
6
+ //
7
+ // It lives in one place deliberately. Phase 1 put a resolver guard at a single
8
+ // call site; the next task re-broke it and it had to be moved into callgraph.js.
9
+ // Five copies of this would recreate that exactly.
10
+
11
+ // Recursively collect every 'call' subexpression inside a lowered expr tree
12
+ // (a call's own args can themselves contain calls, e.g. `foo(bar(x))`).
13
+ // `elements` (array literals) and `props` (object literals) are walked too —
14
+ // added when parser-py-cst.js's own copy of this walker was folded in here,
15
+ // so that e.g. `xs = [foo(x), bar(y)]` still surfaces both call sites for
16
+ // Python. Harmless for other languages: those fields are simply absent from
17
+ // their expr shapes.
18
+ function _collectCallExprs(expr, out) {
19
+ if (!expr || typeof expr !== 'object') return;
20
+ if (expr.kind === 'call') {
21
+ out.push(expr);
22
+ for (const a of expr.args || []) _collectCallExprs(a, out);
23
+ return;
24
+ }
25
+ if (Array.isArray(expr.parts)) for (const p of expr.parts) _collectCallExprs(p, out);
26
+ if (Array.isArray(expr.branches)) for (const b of expr.branches) _collectCallExprs(b, out);
27
+ if (Array.isArray(expr.elements)) for (const e of expr.elements) _collectCallExprs(e, out);
28
+ if (Array.isArray(expr.props)) for (const p of expr.props) _collectCallExprs(p && p.value, out);
29
+ if (expr.left) _collectCallExprs(expr.left, out);
30
+ if (expr.right) _collectCallExprs(expr.right, out);
31
+ if (expr.object) _collectCallExprs(expr.object, out);
32
+ }
33
+
34
+ // Build the `fn.calls` list documented at parser-js.js:19 —
35
+ // `[{ site, callee, args, line }]` — from the CFG. A call can appear at
36
+ // statement position (its own 'call' node) or embedded in another node's
37
+ // expression (an assignment's RHS, a return/throw value, an if condition —
38
+ // `char* p = getenv("CMD")` is exactly the source-introducing shape the
39
+ // taint engine needs to see and must not be missed just because it isn't a
40
+ // bare statement).
41
+ //
42
+ // Known boundaries (not modeled): a call in a `for`-loop's step expression
43
+ // (`for (;; advance(p))`) is not surfaced — the CFG only lowers the loop's
44
+ // test into the `if` node's `cond`; a call as an assignment's LHS/target
45
+ // (not a real C++ shape but a malformed one a fuzz input could produce) is
46
+ // never inspected, only `source`; and a `kind: 'unknown'` statement (a
47
+ // construct `_lowerStmt` couldn't classify) contributes no call sites even
48
+ // if it textually contains one.
49
+ export function callSitesFromCfg(cfg) {
50
+ const sites = [];
51
+ for (const [nodeId, node] of Object.entries((cfg && cfg.nodes) || {})) {
52
+ if (!node) continue;
53
+ let root = null;
54
+ if (node.kind === 'call') root = { kind: 'call', callee: node.callee, args: node.args };
55
+ else if (node.kind === 'assign') root = node.source;
56
+ else if (node.kind === 'return' || node.kind === 'throw') root = node.value;
57
+ else if (node.kind === 'if') root = node.cond;
58
+ if (!root) continue;
59
+ const found = [];
60
+ _collectCallExprs(root, found);
61
+ for (const c of found) {
62
+ sites.push({ site: nodeId, callee: c.callee, args: c.args, line: node.line });
63
+ }
64
+ }
65
+ return sites;
66
+ }