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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.js CHANGED
@@ -1271,13 +1271,75 @@ function _guardWindow(ctx, before = 25, after = 5) {
1271
1271
  .replace(/(^|[^\w'"`])#[^\n]*/g, '$1 ');
1272
1272
  }
1273
1273
 
1274
- function _hasSsrfHostGuard(ctx) { return _SSRF_HOST_GUARD_RE.test(_guardWindow(ctx)); }
1274
+ // PRD R15: a guard-shaped token anywhere in the -25/+5 window used to be
1275
+ // sufficient — an allow-list built for an unrelated purpose, or even the
1276
+ // tainted variable's OWN declaration line, sitting in the same screenful of
1277
+ // code as a genuinely-unguarded sink, silently killed a real finding. This
1278
+ // does not require full dataflow correlation, only a cheap positional one:
1279
+ // the sink's own argument identifier(s) must appear WITHIN A FEW LINES of
1280
+ // the specific line the guard-shaped text actually matched on — not merely
1281
+ // somewhere in the whole window, which is true of almost any variable used
1282
+ // nearby (its own declaration, an unrelated helper, the sink line itself).
1283
+ // Falls back to permissive (unable to correlate → don't break existing
1284
+ // recall protection) when the sink line yields no usable identifier.
1285
+ const _GUARD_STOPWORDS = new Set(['var', 'let', 'const', 'function', 'return', 'new', 'await', 'async',
1286
+ 'if', 'else', 'for', 'while', 'require', 'import', 'from', 'true', 'false', 'null', 'undefined',
1287
+ 'this', 'self', 'req', 'res', 'request', 'response']);
1288
+ function _sinkLineIdentifiers(ctx) {
1289
+ const line = (ctx && Array.isArray(ctx.lines) && ctx.lines[(ctx.line || 1) - 1]) || '';
1290
+ const ids = new Set();
1291
+ // Excludes call-target identifiers (name immediately followed by `(`) —
1292
+ // `fetch(target)` must correlate on the ARGUMENT `target`, not on `fetch`
1293
+ // itself, which incidentally appears anywhere the module imports fetch
1294
+ // (e.g. `const fetch = require('node-fetch')`) and would trivially
1295
+ // "correlate" with any guard window in the same file.
1296
+ const re = /\b[A-Za-z_$][\w$]*\b(?!\s*\()/g;
1297
+ let m;
1298
+ while ((m = re.exec(line))) {
1299
+ const id = m[0];
1300
+ // No minimum length: short variable names (`u`, `p`, `f`) are common,
1301
+ // legitimate taint carriers in real code — excluding them left the
1302
+ // ONLY correlating identifier out entirely for sinks like
1303
+ // `File.ReadAllText(p)` or `axios.get(u.toString())`, defeating
1304
+ // correlation with a guard that genuinely protects that exact
1305
+ // variable (a corpus regression caught this: CVE-2021-22054-ssrf-shape,
1306
+ // CVE-2022-26049-cs-path).
1307
+ if (id.length >= 1 && !_GUARD_STOPWORDS.has(id)) ids.add(id);
1308
+ }
1309
+ return ids;
1310
+ }
1311
+ // Runs guardRe against the window and, for each match, checks whether any
1312
+ // sink-line identifier appears within `span` lines of that match's own line
1313
+ // (excluding the sink line itself, which trivially contains its own
1314
+ // argument). Tries every match, not just the first, since a window can
1315
+ // contain several guard-shaped lines and only one need actually correlate.
1316
+ function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
1317
+ const w = _guardWindow(ctx);
1318
+ const re = new RegExp(guardRe.source, guardRe.flags.includes('g') ? guardRe.flags : guardRe.flags + 'g');
1319
+ const ids = _sinkLineIdentifiers(ctx);
1320
+ if (!ids.size) return re.test(w); // can't correlate — fall back to the old shape-only check
1321
+ const wLines = w.split('\n');
1322
+ const sinkLineText = (ctx.lines && ctx.lines[(ctx.line || 1) - 1]) || '';
1323
+ let m;
1324
+ while ((m = re.exec(w))) {
1325
+ const guardLineIdx = w.slice(0, m.index).split('\n').length - 1; // 0-based within window
1326
+ const lo = Math.max(0, guardLineIdx - span);
1327
+ const hi = Math.min(wLines.length, guardLineIdx + span + 1);
1328
+ const local = wLines.slice(lo, hi).filter((l) => l !== sinkLineText).join('\n');
1329
+ for (const id of ids) {
1330
+ if (new RegExp(`\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(local)) return true;
1331
+ }
1332
+ }
1333
+ return false;
1334
+ }
1335
+
1336
+ function _hasSsrfHostGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _SSRF_HOST_GUARD_RE); }
1275
1337
 
1276
1338
  // A path-traversal containment guard near the file sink: a basename/strip
1277
1339
  // helper that removes directory components, a framework safe-join, or a
1278
1340
  // canonicalize-then-startsWith containment check.
1279
1341
  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/;
1280
- function _hasPathGuard(ctx) { return _PATH_GUARD_RE.test(_guardWindow(ctx)); }
1342
+ function _hasPathGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _PATH_GUARD_RE); }
1281
1343
 
1282
1344
  // Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
1283
1345
  const _XSS_ESCAPER = String.raw`(?:escapeHtml|escape_html|escape-html|sanitizeHtml|sanitize_html|DOMPurify\.sanitize|he\.encode|he\.escape|_\.escape|validator\.escape|bleach\.clean|markupsafe|htmlspecialchars|htmlentities|html\.escape|escapeHTML|encodeURIComponent|escape)\s*\(`;
@@ -3008,15 +3070,17 @@ const JAVA_FAMILY_RULES = [
3008
3070
  return !isWeak(resolved); // strong → suppress; weak → fire
3009
3071
  }
3010
3072
  // 2) OWASP Benchmark fallback — hardcoded answer-key for OWASP's own
3011
- // benchmark.properties file. Pure label leakage; disabled under
3012
- // blind bench so the F1 reflects the production engine alone.
3013
- const _blindHere = process.env.AGENTIC_SECURITY_BLIND_BENCH === '1';
3014
- const OWASP_BENCH_PROPS = _blindHere ? {} : {
3073
+ // benchmark.properties file. Pure label leakage. PRD R5: was
3074
+ // opt-out (disabled only under BLIND_BENCH=1) inverted to opt-in,
3075
+ // enabled only under explicit BENCH_SHAPE=1, matching the
3076
+ // documented default every other bench-shape mechanism follows.
3077
+ const _benchShapeHere = process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1';
3078
+ const OWASP_BENCH_PROPS = _benchShapeHere ? {
3015
3079
  cryptoAlg1: 'DES/ECB/PKCS5Padding',
3016
3080
  cryptoAlg2: 'AES/CCM/NoPadding',
3017
3081
  hashAlg1: 'MD5',
3018
3082
  hashAlg2: 'SHA-256',
3019
- };
3083
+ } : {};
3020
3084
  if (OWASP_BENCH_PROPS[propKey]) {
3021
3085
  return !isWeak(OWASP_BENCH_PROPS[propKey]);
3022
3086
  }
@@ -4464,8 +4528,10 @@ function scanJavaSAST(fp, raw) {
4464
4528
  // 72/73/74/.../82 where the receiving file has no local source — the
4465
4529
  // tainted Vector/List/Map arrives via a method parameter from a sibling
4466
4530
  // file. Gated tightly to avoid FPs on real apps.
4467
- if (!hasSource && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1') {
4468
- // Juliet-shape signal disabled under blind bench (answer-key leakage).
4531
+ if (!hasSource && process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1') {
4532
+ // PRD R5: was opt-out (disabled only under BLIND_BENCH=1) inverted to
4533
+ // opt-in, matching the documented default every other bench-shape
4534
+ // mechanism in this file follows. Juliet-shape signal.
4469
4535
  const _isJulietShape = /\bjuliet\.(?:testcases|support)\b/.test(cleaned)
4470
4536
  || /\b(?:badSink|badSource|goodG2B|goodB2G)\s*\(/.test(cleaned);
4471
4537
  if (_isJulietShape && /\b(?:Vector|ArrayList|LinkedList|List|Set|HashSet|Map|HashMap|Hashtable|Properties|Queue|Deque|Stack|Optional)\s*<[^>]*>\s+[A-Za-z_]\w*\s*[,)]/.test(cleaned)) {
@@ -4485,15 +4551,18 @@ function scanJavaSAST(fp, raw) {
4485
4551
  let pm;
4486
4552
  // OWASP_BENCH_PROPS is the OWASP Benchmark answer-key for its own
4487
4553
  // benchmark.properties file (hashAlg1 → MD5, cryptoAlg1 → DES/ECB). Pure
4488
- // label leakage. Disabled under blind bench; real apps use the
4489
- // properties index loaded from the filesystem instead.
4490
- const _blindHere = process.env.AGENTIC_SECURITY_BLIND_BENCH === '1';
4491
- const OWASP_BENCH_PROPS = _blindHere ? {} : {
4554
+ // label leakage. PRD R5: was opt-out (disabled only under
4555
+ // BLIND_BENCH=1) inverted to opt-in, enabled only under explicit
4556
+ // BENCH_SHAPE=1; real apps use the properties index loaded from the
4557
+ // filesystem instead (the `resolved`/`getJavaProperty` path above this
4558
+ // fallback).
4559
+ const _benchShapeHere = process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1';
4560
+ const OWASP_BENCH_PROPS = _benchShapeHere ? {
4492
4561
  cryptoAlg1: 'DES/ECB/PKCS5Padding',
4493
4562
  cryptoAlg2: 'AES/CCM/NoPadding',
4494
4563
  hashAlg1: 'MD5',
4495
4564
  hashAlg2: 'SHA-256',
4496
- };
4565
+ } : {};
4497
4566
  const isWeak = (v) =>
4498
4567
  /\b(?:MD2|MD4|MD5|SHA-?1|SHA1|DES|DESede|3DES|RC2|RC4|Blowfish|HmacMD5|HmacSHA1)\b|AES\s*\/\s*ECB/i.test(v || '');
4499
4568
  while ((pm = propUseRe.exec(cleaned)) !== null) {
@@ -4824,7 +4893,13 @@ function annotateReachability(findings,routes,callGraph,fc){
4824
4893
  // Within 60 lines of a route declaration we consider this source route-rooted
4825
4894
  const routeRooted=rl.some(l=>Math.abs(l-srcLine)<60);
4826
4895
  f.routeRooted=routeRooted;
4827
- // Cheap function-of-source lookup via callGraph
4896
+ // Cheap function-of-source lookup via callGraph. buildCallGraph only
4897
+ // ever populates entries for .js/.jsx/.ts/.tsx/.mjs/.cjs files (PRD
4898
+ // R15) — callGraph[fp] being absent for every other language is an
4899
+ // ABSENCE OF EVIDENCE, not evidence the finding is unreachable, and
4900
+ // must not be conflated with a JS file whose call graph genuinely has
4901
+ // no incoming edge.
4902
+ const hasCallGraphData=Object.prototype.hasOwnProperty.call(callGraph,fp);
4828
4903
  const funcs=callGraph[fp]||{};
4829
4904
  let enclosing=null;
4830
4905
  for(const[fn,info] of Object.entries(funcs))
@@ -4833,6 +4908,7 @@ function annotateReachability(findings,routes,callGraph,fc){
4833
4908
  // Reachable when route-rooted OR enclosingFunction is called from any function
4834
4909
  // declared near a route in the same file
4835
4910
  if(routeRooted){f.reachable=true;continue;}
4911
+ if(!hasCallGraphData){f.reachable=null;continue;}
4836
4912
  let reachable=false;
4837
4913
  if(enclosing){
4838
4914
  for(const rLine of rl){
@@ -5822,15 +5898,26 @@ function dedupeFindingsWithEvidence(findings){
5822
5898
  const key=`${file}:${sinkLine}:${fam}`;
5823
5899
  if(!buckets.has(key)){buckets.set(key,f);continue;}
5824
5900
  const kept=buckets.get(key);
5825
- // Winner selection: an interprocedural flow finding (carries source→sink
5826
- // attribution) is the better carrier than a flat structural/regex match at
5827
- // the same sinkkeep its chain/source-line attribution. When neither or
5828
- // both carry flow, fall back to severity. The winner keeps its own severity
5829
- // (we must not resurrect a rating that ownership/reachability analysis
5830
- // deliberately downgraded on one of the two findings).
5901
+ // Winner selection: an IR-TAINT finding (the deep engine's real
5902
+ // interprocedural taint walk) is the best carrier at a shared sink,
5903
+ // ahead of everything else PRD R3: it carries taint-walk-only evidence
5904
+ // (sanitizer observations keyed off the actual value reaching the sink,
5905
+ // chain, LLM-validation state) that a flat pattern/AST match has no
5906
+ // equivalent for, and that evidence would silently vanish if a
5907
+ // same-severity pattern-layer duplicate won the tie instead. Below that,
5908
+ // an interprocedural flow finding (carries source→sink attribution) is
5909
+ // the better carrier than a flat structural/regex match — keep its
5910
+ // chain/source-line attribution. When neither or both carry flow, fall
5911
+ // back to severity. The winner keeps its own severity (we must not
5912
+ // resurrect a rating that ownership/reachability analysis deliberately
5913
+ // downgraded on one of the two findings).
5914
+ const fIsIrTaint = f.parser === 'IR-TAINT';
5915
+ const kIsIrTaint = kept.parser === 'IR-TAINT';
5831
5916
  const fHasFlow = !!(f.source && f.sink);
5832
5917
  const kHasFlow = !!(kept.source && kept.sink);
5833
- const keepNew = (fHasFlow !== kHasFlow)
5918
+ const keepNew = (fIsIrTaint !== kIsIrTaint)
5919
+ ? fIsIrTaint
5920
+ : (fHasFlow !== kHasFlow)
5834
5921
  ? fHasFlow
5835
5922
  : (SEV_RANK[f.severity]??9) < (SEV_RANK[kept.severity]??9);
5836
5923
  const winner = keepNew ? f : kept;
@@ -7817,7 +7904,15 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7817
7904
  const c = fc[p];
7818
7905
  if (!c) continue;
7819
7906
  // Path-based category for Juliet test cases: `juliet-cweN/.../...java`.
7820
- const julietMatch = p.match(/(?:^|\/)juliet-cwe(\d+)\//i);
7907
+ // PRD R5: this reads a path-embedded answer key (the directory name
7908
+ // declares the CWE) exactly like _javaWebServletCategory's @WebServlet
7909
+ // annotation reading below — off by default, enabled only when
7910
+ // BENCH_SHAPE=1. This branch previously had no gate at all, so a real
7911
+ // repository with a directory that happens to be named `juliet-cweNN/`
7912
+ // would silently lose off-family findings in the default pipeline.
7913
+ const julietMatch = (process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1'
7914
+ && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1')
7915
+ ? p.match(/(?:^|\/)juliet-cwe(\d+)\//i) : null;
7821
7916
  if (julietMatch && _JULIET_CWE_TO_FAMILY[julietMatch[1]]) {
7822
7917
  _benchCategoryByFile.set(p, _JULIET_CWE_TO_FAMILY[julietMatch[1]]);
7823
7918
  continue;
@@ -8030,6 +8125,169 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8030
8125
  // Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
8031
8126
  // AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
8032
8127
  if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
8128
+ // Phase 3 (Sentinel-parity FR-L1, FR-L2) — IR + interprocedural taint.
8129
+ // R1 (PRD §5): the CLI entry (bin/agentic-security.js#cmdScan) now sets
8130
+ // AGENTIC_SECURITY_DEEP=1 by default for local/interactive scans, so deep mode
8131
+ // runs on the default `/scan --all` path. This gate is the enforcement +
8132
+ // CI-safety point: it honors an explicit opt-out (DEEP=0) and keeps deep off in
8133
+ // CI unless DEEP_IN_CI=1. In-process callers (tests, the cve-replay corpus) invoke
8134
+ // runScan()/runFullScan() directly without the CLI default, so they stay deep-off
8135
+ // and remain deterministic regression gates.
8136
+ //
8137
+ // PRD R3: IR-TAINT findings are appended into `aF` HERE, before dedup, so
8138
+ // they dedupe against a pattern-layer duplicate of the same sink and then
8139
+ // ride through the exact same annotator pipeline every other finding does
8140
+ // (stable IDs, clustering, reachability, family backfill, confidence,
8141
+ // calibration, exploitability, sanitizer/proof gate, mitigation, composite
8142
+ // risk, LLM validation...) below. Previously this block ran AFTER that
8143
+ // entire pipeline and pushed straight into the post-dedup `finalFindings`
8144
+ // array, so a sink caught by both the regex layer and deep mode produced
8145
+ // two findings (one with no family and no calibrated confidence) instead
8146
+ // of one deduped, fully-annotated finding.
8147
+ //
8148
+ // SAFETY: Deep mode is gated for CI safety:
8149
+ // - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
8150
+ // - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
8151
+ // so a pathological file can't hang the whole pipeline.
8152
+ // ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
8153
+ // Built ahead of the deep-mode gate so coverage is measurable without paying
8154
+ // for taint analysis, and stashed in _sharedIR so the deep block below reuses
8155
+ // it rather than parsing the project twice.
8156
+ //
8157
+ // NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
8158
+ // when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
8159
+ // budget timer (t0) below is started. On an uninstrumented run, IR
8160
+ // construction instead happens inside the timed block via the
8161
+ // `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
8162
+ // against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
8163
+ // NOT account for parse time when stats are enabled — an instrumented run
8164
+ // gets strictly more wall-clock for the taint analysis itself than an
8165
+ // uninstrumented run with the same budget.
8166
+ let _sharedIR = null;
8167
+ // Java IR requires the ASYNC builder. `parser-java.js` exports an async
8168
+ // `parseJavaFile` (java-parser needs a dynamic import), so the sync
8169
+ // `buildProjectIR` has no Java branch at all — and both deep-path call sites
8170
+ // used it. The result was that no .java file had ever produced an IR function
8171
+ // in deep mode: `bench/layer-recall` measured java at 0/25 while the catalog
8172
+ // carried 7 Java sources and 15 Java sinks that had nothing to run against.
8173
+ // `buildProjectIRAsync` is a full mirror plus Java and had zero callers.
8174
+ //
8175
+ // Gated on the presence of .java rather than always awaiting: the async
8176
+ // builder is a superset, but switching every scan in the product to it to fix
8177
+ // one language would change the execution shape (and attempt the java-parser
8178
+ // import) for projects that contain no Java. `runFullScan` is already async,
8179
+ // so the await costs nothing structurally.
8180
+ const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
8181
+ const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
8182
+ const _irStatsTarget = irStatsTarget();
8183
+ if (_irStatsTarget) {
8184
+ try {
8185
+ _sharedIR = await _buildIR();
8186
+ writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
8187
+ } catch (e) {
8188
+ // Instrumentation must never fail a scan. Surface only when debugging.
8189
+ if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
8190
+ process.stderr.write(`ir-stats: ${e && e.message}\n`);
8191
+ }
8192
+ }
8193
+ }
8194
+ // `deep`/`deepInCi` come from runScan()'s options object (threaded through
8195
+ // unchanged from runScan.js) — an explicit-opt-in override alongside the
8196
+ // env vars, not a replacement for them. Added because `runScan(dir,
8197
+ // {deep:true})` was a silent, total no-op: this options object was
8198
+ // destructured for fileContents/depFileContents/scanRoot/resume only, so
8199
+ // `deep` was dropped on the floor and deep mode stayed off regardless.
8200
+ // Several interprocedural test files (interproc-k2.test.js,
8201
+ // parser-cs-kt.test.js, points-to.test.js) pass exactly this option
8202
+ // believing it enables deep mode — it never did, so those tests were
8203
+ // exercising whatever coincidentally fires without the deep engine, not
8204
+ // the interprocedural machinery they're named for.
8205
+ const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1';
8206
+ const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
8207
+ process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
8208
+ const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8209
+ const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
8210
+ let _deepCallGraph = null;
8211
+ if (_deepEnabled) {
8212
+ const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
8213
+ const t0 = Date.now();
8214
+ try {
8215
+ const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
8216
+ _deepCallGraph = callGraph;
8217
+ // The runDeepAnalysis call is synchronous in this codebase; we can't
8218
+ // truly interrupt it without re-architecting the worklist. We pass a
8219
+ // deadlineMs hint that the inner loops check; if absent, we still cap
8220
+ // function count via fnLimit. Operators who suspect a hung run can
8221
+ // kill the process and re-run with AGENTIC_SECURITY_DEEP=0.
8222
+ const irFindings = runDeepAnalysis(perFile, callGraph, {
8223
+ fnLimit: parseInt(process.env.AGENTIC_SECURITY_DEEP_FN_LIMIT || '5000', 10),
8224
+ deadlineMs: t0 + budgetMs,
8225
+ // v0.69 — incremental cache inputs (used when AGENTIC_SECURITY_INCREMENTAL=1).
8226
+ scanRoot,
8227
+ fileContents: fc,
8228
+ });
8229
+ const elapsed = Date.now() - t0;
8230
+ if (elapsed > budgetMs) {
8231
+ // We exceeded budget — surface a single info finding so operators see it.
8232
+ aF.push({
8233
+ id: `ir-taint-timeout:${scanRoot || ''}`,
8234
+ file: '(deep-engine)', line: 0,
8235
+ vuln: `IR-TAINT deep mode exceeded ${budgetMs}ms budget (${elapsed}ms used) — results may be incomplete`,
8236
+ severity: 'info',
8237
+ parser: 'IR-TAINT',
8238
+ confidence: 0.5,
8239
+ });
8240
+ }
8241
+ for (const f of irFindings) {
8242
+ f.unvalidated = true;
8243
+ f.validator_verdict = 'unvalidated';
8244
+ }
8245
+ aF.push(...irFindings);
8246
+ } catch (e) {
8247
+ // Deep mode is best-effort. A parser blowup in one file shouldn't kill
8248
+ // the scan — fall back to the pattern-only result.
8249
+ }
8250
+ } else if (_deepRequested && _inCi) {
8251
+ // Operator asked for deep but we're in CI — emit a non-blocking notice
8252
+ // so they know it was skipped and how to override.
8253
+ aF.push({
8254
+ id: 'ir-taint-ci-skipped',
8255
+ file: '(deep-engine)', line: 0,
8256
+ vuln: 'IR-TAINT deep mode skipped in CI environment (set AGENTIC_SECURITY_DEEP_IN_CI=1 to opt in)',
8257
+ severity: 'info',
8258
+ parser: 'IR-TAINT',
8259
+ confidence: 1.0,
8260
+ });
8261
+ }
8262
+ // Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
8263
+ if (_deepCallGraph) {
8264
+ try {
8265
+ for (const sc of supplyChain) {
8266
+ if (sc.type !== 'vulnerable_dep' || sc.ecosystem !== 'maven') continue;
8267
+ if (sc.functionReachable === 'reachable') continue;
8268
+ const allFns = [...(sc.osvVulnFunctions || []), ...(VULN_FUNCTION_HINTS[sc.name] || [])];
8269
+ if (!allFns.length) continue;
8270
+ for (const fn of _deepCallGraph.functions ? _deepCallGraph.functions.values() : []) {
8271
+ if (!fn.cfg || !fn.cfg.nodes) continue;
8272
+ for (const node of Object.values(fn.cfg.nodes)) {
8273
+ if (node.kind !== 'call') continue;
8274
+ const callee = typeof node.callee === 'string' ? node.callee : null;
8275
+ if (!callee) continue;
8276
+ const shortCallee = callee.includes('.') ? callee.split('.').pop() : callee;
8277
+ if (allFns.some(f => f === shortCallee || f === callee)) {
8278
+ sc.functionReachable = 'reachable';
8279
+ sc.reachabilityTier = 'function-reachable';
8280
+ if (!sc.vulnerableFunctionCallSites) sc.vulnerableFunctionCallSites = [];
8281
+ sc.vulnerableFunctionCallSites.push({ pkg: sc.name, fn: shortCallee, file: fn.file, line: node.line });
8282
+ sc._javaIrEnriched = true;
8283
+ break;
8284
+ }
8285
+ }
8286
+ if (sc.functionReachable === 'reachable') break;
8287
+ }
8288
+ }
8289
+ } catch { /* Java SCA enrichment is best-effort */ }
8290
+ }
8033
8291
  let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
8034
8292
  // Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
8035
8293
  // finding that exists BY THIS POINT — the pattern detectors, the cross-file
@@ -8453,190 +8711,6 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8453
8711
  // FR-LOGIC-6: LLM-driven flow narration (template fallback when no LLM endpoint).
8454
8712
  try { await annotateNarration(finalFindings); }
8455
8713
  catch (e) { _annotatorErrors.push({ phase: 'annotateNarration', err: String((e && e.message) || e) }); }
8456
- // Phase 3 (Sentinel-parity FR-L1, FR-L2) — IR + interprocedural taint.
8457
- // R1 (PRD §5): the CLI entry (bin/agentic-security.js#cmdScan) now sets
8458
- // AGENTIC_SECURITY_DEEP=1 by default for local/interactive scans, so deep mode
8459
- // runs on the default `/scan --all` path. This gate is the enforcement +
8460
- // CI-safety point: it honors an explicit opt-out (DEEP=0) and keeps deep off in
8461
- // CI unless DEEP_IN_CI=1. In-process callers (tests, the cve-replay corpus) invoke
8462
- // runScan()/runFullScan() directly without the CLI default, so they stay deep-off
8463
- // and remain deterministic regression gates. Findings ride through the standard
8464
- // dedup/cluster/confidence pipeline below and the LLM-validator stage that follows.
8465
- //
8466
- // SAFETY: Deep mode is gated for CI safety:
8467
- // - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
8468
- // - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
8469
- // so a pathological file can't hang the whole pipeline.
8470
- // ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
8471
- // Built ahead of the deep-mode gate so coverage is measurable without paying
8472
- // for taint analysis, and stashed in _sharedIR so the deep block below reuses
8473
- // it rather than parsing the project twice.
8474
- //
8475
- // NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
8476
- // when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
8477
- // budget timer (t0) below is started. On an uninstrumented run, IR
8478
- // construction instead happens inside the timed block via the
8479
- // `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
8480
- // against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
8481
- // NOT account for parse time when stats are enabled — an instrumented run
8482
- // gets strictly more wall-clock for the taint analysis itself than an
8483
- // uninstrumented run with the same budget.
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));
8500
- const _irStatsTarget = irStatsTarget();
8501
- if (_irStatsTarget) {
8502
- try {
8503
- _sharedIR = await _buildIR();
8504
- writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
8505
- } catch (e) {
8506
- // Instrumentation must never fail a scan. Surface only when debugging.
8507
- if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
8508
- process.stderr.write(`ir-stats: ${e && e.message}\n`);
8509
- }
8510
- }
8511
- }
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';
8524
- const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
8525
- process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
8526
- const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8527
- const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
8528
- if (_deepEnabled) {
8529
- const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
8530
- const t0 = Date.now();
8531
- try {
8532
- const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
8533
- // The runDeepAnalysis call is synchronous in this codebase; we can't
8534
- // truly interrupt it without re-architecting the worklist. We pass a
8535
- // deadlineMs hint that the inner loops check; if absent, we still cap
8536
- // function count via fnLimit. Operators who suspect a hung run can
8537
- // kill the process and re-run with AGENTIC_SECURITY_DEEP=0.
8538
- const irFindings = runDeepAnalysis(perFile, callGraph, {
8539
- fnLimit: parseInt(process.env.AGENTIC_SECURITY_DEEP_FN_LIMIT || '5000', 10),
8540
- deadlineMs: t0 + budgetMs,
8541
- // v0.69 — incremental cache inputs (used when AGENTIC_SECURITY_INCREMENTAL=1).
8542
- scanRoot,
8543
- fileContents: fc,
8544
- });
8545
- const elapsed = Date.now() - t0;
8546
- if (elapsed > budgetMs) {
8547
- // We exceeded budget — surface a single info finding so operators see it.
8548
- finalFindings.push({
8549
- id: `ir-taint-timeout:${scanRoot || ''}`,
8550
- file: '(deep-engine)', line: 0,
8551
- vuln: `IR-TAINT deep mode exceeded ${budgetMs}ms budget (${elapsed}ms used) — results may be incomplete`,
8552
- severity: 'info',
8553
- parser: 'IR-TAINT',
8554
- confidence: 0.5,
8555
- });
8556
- }
8557
- for (const f of irFindings) {
8558
- f.unvalidated = true;
8559
- f.validator_verdict = 'unvalidated';
8560
- }
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
- }
8585
- // Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
8586
- // findings land here, long after pass 1 ran, so without this an
8587
- // `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
8588
- // what the CLI uses outside CI and taint findings are the ones users most
8589
- // want to silence, so the documented feature did nothing in the case that
8590
- // mattered most.
8591
- //
8592
- // Re-running over the already-filtered array is safe and does not
8593
- // double-log: pass 1's removals are gone from `finalFindings`, so only the
8594
- // newly-appended IR findings can match here, and each suppression reaches
8595
- // the ledger exactly once.
8596
- try{ _applyIgnorePragmas(finalFindings, fc); }catch(_){}
8597
- // Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
8598
- try {
8599
- for (const sc of supplyChain) {
8600
- if (sc.type !== 'vulnerable_dep' || sc.ecosystem !== 'maven') continue;
8601
- if (sc.functionReachable === 'reachable') continue;
8602
- const allFns = [...(sc.osvVulnFunctions || []), ...(VULN_FUNCTION_HINTS[sc.name] || [])];
8603
- if (!allFns.length) continue;
8604
- for (const fn of callGraph.functions ? callGraph.functions.values() : []) {
8605
- if (!fn.cfg || !fn.cfg.nodes) continue;
8606
- for (const node of Object.values(fn.cfg.nodes)) {
8607
- if (node.kind !== 'call') continue;
8608
- const callee = typeof node.callee === 'string' ? node.callee : null;
8609
- if (!callee) continue;
8610
- const shortCallee = callee.includes('.') ? callee.split('.').pop() : callee;
8611
- if (allFns.some(f => f === shortCallee || f === callee)) {
8612
- sc.functionReachable = 'reachable';
8613
- sc.reachabilityTier = 'function-reachable';
8614
- if (!sc.vulnerableFunctionCallSites) sc.vulnerableFunctionCallSites = [];
8615
- sc.vulnerableFunctionCallSites.push({ pkg: sc.name, fn: shortCallee, file: fn.file, line: node.line });
8616
- sc._javaIrEnriched = true;
8617
- break;
8618
- }
8619
- }
8620
- if (sc.functionReachable === 'reachable') break;
8621
- }
8622
- }
8623
- } catch { /* Java SCA enrichment is best-effort */ }
8624
- } catch (e) {
8625
- // Deep mode is best-effort. A parser blowup in one file shouldn't kill
8626
- // the scan — fall back to the pattern-only result.
8627
- }
8628
- } else if (_deepRequested && _inCi) {
8629
- // Operator asked for deep but we're in CI — emit a non-blocking notice
8630
- // so they know it was skipped and how to override.
8631
- finalFindings.push({
8632
- id: 'ir-taint-ci-skipped',
8633
- file: '(deep-engine)', line: 0,
8634
- vuln: 'IR-TAINT deep mode skipped in CI environment (set AGENTIC_SECURITY_DEEP_IN_CI=1 to opt in)',
8635
- severity: 'info',
8636
- parser: 'IR-TAINT',
8637
- confidence: 1.0,
8638
- });
8639
- }
8640
8714
  // Phase 2 (Sentinel-parity): LLM validator stage. DEFAULT-ON whenever
8641
8715
  // AGENTIC_SECURITY_LLM_ENDPOINT is configured — not gated on
8642
8716
  // AGENTIC_SECURITY_LLM_VALIDATE=1 as this comment previously (and wrongly)