@clear-capabilities/agentic-security-scanner 0.134.0 → 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.
- package/CHANGELOG.md +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +9 -1
- package/src/sca/sarif-ingest.js +0 -187
package/src/dataflow/engine.js
CHANGED
|
@@ -30,14 +30,17 @@
|
|
|
30
30
|
// Sinks: anywhere a CFG node calls a catalog-registered sink with a tainted
|
|
31
31
|
// argument, we emit a finding.
|
|
32
32
|
//
|
|
33
|
-
// Sanitizers:
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
33
|
+
// Sanitizers: RECORDED, but they do not kill taint in this walk.
|
|
34
|
+
// `_sanitizersForExpr` (below) collects the sanitizer callees applied to the
|
|
35
|
+
// value reaching each sink argument — inline, or inherited via
|
|
36
|
+
// `_sanitizersByVar` from the variable it reads — and stamps them on the
|
|
37
|
+
// finding as `_sanitizersOnPath`. `dataflow/sanitizer-gate.js` then labels the
|
|
38
|
+
// finding `sanitized:true` only when the sanitizer's `appliesTo` family
|
|
39
|
+
// actually covers the finding's threat class; `engine.js`'s proof gate demotes
|
|
40
|
+
// from there. Taint itself still dies only when a variable is re-assigned from
|
|
41
|
+
// a clean expression (removePathAndDescendants, below) — a mislabelled
|
|
42
|
+
// sanitizer must never silently drop a real vulnerability, so the walk never
|
|
43
|
+
// treats a sanitizer call as clearing the tainted path on its own.
|
|
41
44
|
|
|
42
45
|
import { matchSource, matchSinkOrSanitizer } from './catalog.js';
|
|
43
46
|
import { functionRecord } from '../ir/callgraph.js';
|
|
@@ -268,6 +271,53 @@ function _matchCallCatalog(calleeExpr, argExprs, state) {
|
|
|
268
271
|
return { cat, argTaints };
|
|
269
272
|
}
|
|
270
273
|
|
|
274
|
+
// Sanitizer callees observed on an expression.
|
|
275
|
+
//
|
|
276
|
+
// The engine deliberately does NOT let a sanitizer kill taint. A blanket
|
|
277
|
+
// "any sanitizer clears the flow" rule scores well on benchmarks and silently
|
|
278
|
+
// drops a real SQL injection whenever the code applied an HTML escaper — the
|
|
279
|
+
// C/C++ catalog work already found strncpy/snprintf tagged effect:'strip' when
|
|
280
|
+
// they bound length rather than sanitising content. So the walk RECORDS which
|
|
281
|
+
// sanitizers touched the value and hands them to sanitizer-gate.js, which
|
|
282
|
+
// labels the finding, and the proof gate demotes it. Recall-preserving, same
|
|
283
|
+
// precedent as falsification.js / proof-gate.js: never removed, never
|
|
284
|
+
// severity-touched.
|
|
285
|
+
function _sanitizersInExprTree(expr, out) {
|
|
286
|
+
if (!expr || typeof expr !== 'object') return;
|
|
287
|
+
if (expr.kind === 'call') {
|
|
288
|
+
const cat = matchSinkOrSanitizer(expr.callee, _currentFile);
|
|
289
|
+
if (cat) {
|
|
290
|
+
for (const e of cat) {
|
|
291
|
+
if (e.kind === 'sanitizer' && e.match && e.match.callee) out.add(e.match.callee);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const k of ['left', 'right', 'callee', 'object', 'property', 'value']) {
|
|
296
|
+
if (expr[k] && typeof expr[k] === 'object') _sanitizersInExprTree(expr[k], out);
|
|
297
|
+
}
|
|
298
|
+
for (const k of ['args', 'parts', 'branches', 'elements']) {
|
|
299
|
+
if (Array.isArray(expr[k])) for (const e of expr[k]) _sanitizersInExprTree(e, out);
|
|
300
|
+
}
|
|
301
|
+
if (Array.isArray(expr.props)) for (const p of expr.props) _sanitizersInExprTree(p && p.value, out);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Sanitizers applied to `expr`: those called inline within it, plus those
|
|
305
|
+
// recorded against any variable it reads (`const safe = escapeHtml(x); sink(safe)`).
|
|
306
|
+
function _sanitizersForExpr(expr, callContext) {
|
|
307
|
+
const out = new Set();
|
|
308
|
+
_sanitizersInExprTree(expr, out);
|
|
309
|
+
const byVar = callContext && callContext._sanitizersByVar;
|
|
310
|
+
if (byVar && byVar.size) {
|
|
311
|
+
const vars = new Set();
|
|
312
|
+
_collectExprVars(expr, vars);
|
|
313
|
+
for (const v of vars) {
|
|
314
|
+
const s = byVar.get(v);
|
|
315
|
+
if (s) for (const n of s) out.add(n);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
271
321
|
// cat / argTaints: the result of _matchCallCatalog (computed by the caller,
|
|
272
322
|
// at whatever point in its case is appropriate for its own state-mutation
|
|
273
323
|
// ordering).
|
|
@@ -299,7 +349,13 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
299
349
|
? reachingSources.slice(0, 5)
|
|
300
350
|
// Fallback: better to surface "no precise source" than the wrong source.
|
|
301
351
|
: [];
|
|
352
|
+
// Sanitizers seen on the value reaching THIS argument. Consumed by
|
|
353
|
+
// sanitizer-gate.js, which labels only when the sanitizer's family
|
|
354
|
+
// covers the finding's threat class — an xss escaper on a SQL sink
|
|
355
|
+
// must not read as sanitised.
|
|
356
|
+
const _sanNames = _sanitizersForExpr(taintedArgExpr, callContext);
|
|
302
357
|
findings.push({
|
|
358
|
+
...(_sanNames.size ? { _sanitizersOnPath: [..._sanNames] } : {}),
|
|
303
359
|
kind: 'taint',
|
|
304
360
|
sinkId: e.id,
|
|
305
361
|
vuln: e.vuln?.name || 'Tainted Sink',
|
|
@@ -318,9 +374,36 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
318
374
|
return { findings };
|
|
319
375
|
}
|
|
320
376
|
|
|
377
|
+
// Surfaces a cached (or freshly computed) summary's `findings` into the
|
|
378
|
+
// CURRENT caller's context — the only place a class-field/k=2 pre-pass's
|
|
379
|
+
// speculative findings (in runTaintEngine) become reportable, because
|
|
380
|
+
// reaching this point means a real call site actually consulted that exact
|
|
381
|
+
// qid+entry. Called uniformly on both a cache HIT (`summaryCache.get()`)
|
|
382
|
+
// and a cache MISS (`summaryCache.compute()`'s return value), so it doesn't
|
|
383
|
+
// matter whether this call site is the first one to ever reach this
|
|
384
|
+
// qid+entry or the fifth — findings ride on the summary object itself now,
|
|
385
|
+
// not on a one-shot merge inside compute()'s callback. Module-level (not
|
|
386
|
+
// nested in runTaintEngine) because step()'s assign/plain-call interproc
|
|
387
|
+
// branches call it too, and step() is a top-level function with no access
|
|
388
|
+
// to runTaintEngine's locals.
|
|
389
|
+
function _mergeSummaryFindings(callContext, callerQid, sum, via) {
|
|
390
|
+
if (!sum || !Array.isArray(sum.findings) || !sum.findings.length) return;
|
|
391
|
+
callContext._findings.push(...sum.findings.map(f => ({ ...f, _funcQid: callerQid || null, _via: via })));
|
|
392
|
+
}
|
|
393
|
+
|
|
321
394
|
// Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
|
|
322
395
|
function step(node, stateIn, callContext) {
|
|
323
|
-
const
|
|
396
|
+
// `let`, not `const` — the 'call' case (built-in-mutation and mutated-param
|
|
397
|
+
// branches below) reassigns this binding. It was `const` until Stage 3 of
|
|
398
|
+
// the correctness audit: a bare-statement call to Object.assign/_.merge/
|
|
399
|
+
// etc. with a tainted source arg, or any plain call whose callee summary
|
|
400
|
+
// reports mutated params, threw "Assignment to constant variable" here.
|
|
401
|
+
// The engine's per-function analyzeFunction() call sites all wrap in a
|
|
402
|
+
// blanket try/catch, so the exception was silent — and it discarded every
|
|
403
|
+
// finding already collected for the ENTIRE containing function, not just
|
|
404
|
+
// the mutation site, since the throw unwound past `findings.push(...)`
|
|
405
|
+
// calls for unrelated sinks earlier in the same function body.
|
|
406
|
+
let state = new Set(stateIn);
|
|
324
407
|
const findings = [];
|
|
325
408
|
|
|
326
409
|
switch (node.kind) {
|
|
@@ -348,6 +431,18 @@ function step(node, stateIn, callContext) {
|
|
|
348
431
|
node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
|
|
349
432
|
state, callContext, node.line).findings);
|
|
350
433
|
}
|
|
434
|
+
// Record which sanitizers were applied to the value now held by `target`
|
|
435
|
+
// (inline in the RHS, or inherited from the vars the RHS reads). Placed
|
|
436
|
+
// before every early return in this case so the map cannot go stale on
|
|
437
|
+
// the interprocedural paths below. A clean RHS clears the entry, mirroring
|
|
438
|
+
// removePathAndDescendants — a stale sanitizer would label a later,
|
|
439
|
+
// genuinely unsanitized flow.
|
|
440
|
+
if (target) {
|
|
441
|
+
const _san = _sanitizersForExpr(node.source, callContext);
|
|
442
|
+
const _byVar = (callContext._sanitizersByVar ||= new Map());
|
|
443
|
+
if (_san.size) _byVar.set(target, _san);
|
|
444
|
+
else _byVar.delete(target);
|
|
445
|
+
}
|
|
351
446
|
// Constant propagation: track variables assigned from literals
|
|
352
447
|
if (target && _activeConstantVars) {
|
|
353
448
|
if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
|
|
@@ -404,10 +499,19 @@ function step(node, stateIn, callContext) {
|
|
|
404
499
|
returnTainted: !!inner._returnTainted,
|
|
405
500
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
406
501
|
taintedGlobals: new Set(),
|
|
407
|
-
findings
|
|
502
|
+
// Real findings from the callee's own body — e.g.
|
|
503
|
+
// `function makeQuery(id){ db.query(...id) } ... makeQuery(uid)`
|
|
504
|
+
// — ride on the summary itself (was hardcoded `[]`, so
|
|
505
|
+
// nothing ever read inner._findings and the SQLi inside
|
|
506
|
+
// makeQuery was silently dropped). _mergeSummaryFindings
|
|
507
|
+
// below surfaces them into THIS caller now that a real
|
|
508
|
+
// call site has been established, and does the same on a
|
|
509
|
+
// future cache hit from any other real caller.
|
|
510
|
+
findings: inner._findings,
|
|
408
511
|
};
|
|
409
512
|
});
|
|
410
513
|
}
|
|
514
|
+
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
411
515
|
if (sum && sum.returnTainted) {
|
|
412
516
|
newState = _addPathAliasAware(newState, target, callContext);
|
|
413
517
|
callContext._taintSources.push({
|
|
@@ -515,10 +619,14 @@ function step(node, stateIn, callContext) {
|
|
|
515
619
|
returnTainted: !!inner._returnTainted,
|
|
516
620
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
517
621
|
taintedGlobals: new Set(),
|
|
518
|
-
|
|
622
|
+
// See the sibling assign-call-site compute() above — same
|
|
623
|
+
// fix, same reason: this callee's own findings were
|
|
624
|
+
// computed correctly and then thrown away (hardcoded `[]`).
|
|
625
|
+
findings: inner._findings,
|
|
519
626
|
};
|
|
520
627
|
});
|
|
521
628
|
}
|
|
629
|
+
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
522
630
|
if (sum && sum.mutatedParams && sum.mutatedParams.size) {
|
|
523
631
|
const mutated = callContext._summaryCache.applyAtCallSite(
|
|
524
632
|
sum, paramNames, node.args || [], state);
|
|
@@ -574,7 +682,13 @@ function step(node, stateIn, callContext) {
|
|
|
574
682
|
if (dot <= 0) return null;
|
|
575
683
|
const recv = callee.slice(0, dot);
|
|
576
684
|
const recvTainted = isCoveredBy(state, recv);
|
|
577
|
-
|
|
685
|
+
// higherOrderTaintFlow requires a flattened STRING callee (its own
|
|
686
|
+
// `typeof callee !== 'string'` guard) — passing the raw `node` here
|
|
687
|
+
// handed it a JS/TS structured callee expr ({kind:'member',...})
|
|
688
|
+
// unconditionally, which never passed that guard, so this feature
|
|
689
|
+
// was entirely dead for JS/TS (the primary catalogued language).
|
|
690
|
+
// `callee` here is `_plainCallCalleeName`, already flattened above.
|
|
691
|
+
return higherOrderTaintFlow({ ...node, callee }, recvTainted);
|
|
578
692
|
})();
|
|
579
693
|
if (hoFlow && hoFlow.taintsCallbackParam === 0) {
|
|
580
694
|
// The first arg should be the callback. If it's a plain ident or
|
|
@@ -587,7 +701,17 @@ function step(node, stateIn, callContext) {
|
|
|
587
701
|
if (cb && (cb.kind === 'ident' || cb.kind === 'function-value')) {
|
|
588
702
|
callContext._higherOrderInvocations = callContext._higherOrderInvocations || [];
|
|
589
703
|
callContext._higherOrderInvocations.push({
|
|
590
|
-
|
|
704
|
+
// A resolved-by-name callback (`arr.map(processItem)`) and an
|
|
705
|
+
// inline callback (`arr.map(x => ...)`, parser-js.js's
|
|
706
|
+
// exprOf now emits {kind:'function-value', qid}) need different
|
|
707
|
+
// resolution strategies downstream — a bare name looked up via
|
|
708
|
+
// the call graph's byNameInFile index (ambiguous/guessable) vs.
|
|
709
|
+
// an exact qid looked up directly in callGraph.functions. Kept
|
|
710
|
+
// as two fields rather than overloading `callee` with either
|
|
711
|
+
// shape, so the consumer can't accidentally hand a qid to the
|
|
712
|
+
// name-based resolver (or vice versa).
|
|
713
|
+
callee: cb.kind === 'ident' ? cb.name : null,
|
|
714
|
+
calleeQid: cb.kind === 'function-value' ? (cb.qid || null) : null,
|
|
591
715
|
paramIndex: 0,
|
|
592
716
|
taintedParam: true,
|
|
593
717
|
line: node.line,
|
|
@@ -695,8 +819,29 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
695
819
|
const t = implicitAssignTarget(nodes[nid], ctx);
|
|
696
820
|
if (t) implicitState = markImplicitTaint(implicitState, t);
|
|
697
821
|
}
|
|
698
|
-
//
|
|
699
|
-
//
|
|
822
|
+
// Stage 6 correctness audit: these are two genuinely different gates,
|
|
823
|
+
// previously conflated into one loop over `ictx`. `allConst` is a
|
|
824
|
+
// leak from the SINK CALL'S OWN EXECUTION revealing the branch was
|
|
825
|
+
// taken — that requires the call itself to be genuinely inside the
|
|
826
|
+
// tainted branch (now correctly dominance-scoped by
|
|
827
|
+
// buildImplicitContext, see its header). `argRefsImplicit` is a leak
|
|
828
|
+
// from a VARIABLE that was implicit-tainted earlier — once a var is
|
|
829
|
+
// marked, its taint is a normal fact about the var, not about where
|
|
830
|
+
// it's later read; requiring the READ site to also be lexically
|
|
831
|
+
// inside a branch would miss the canonical
|
|
832
|
+
// `if (tainted) { p = x; } eval(p)` pattern the moment `eval(p)` is
|
|
833
|
+
// (correctly) recognized as being outside the branch.
|
|
834
|
+
const reportedNids = new Set();
|
|
835
|
+
const reportImplicit = (nid, node, sink, conditionLabel) => {
|
|
836
|
+
if (reportedNids.has(nid)) return;
|
|
837
|
+
reportedNids.add(nid);
|
|
838
|
+
callContext._findings.push({
|
|
839
|
+
...createImplicitFinding(node, conditionLabel),
|
|
840
|
+
_funcQid: fn.qid, sinkId: sink.id,
|
|
841
|
+
cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
|
|
842
|
+
});
|
|
843
|
+
};
|
|
844
|
+
// Pass 1 — constant-arg sink calls genuinely inside a tainted branch.
|
|
700
845
|
for (const [nid, ctx] of ictx) {
|
|
701
846
|
const node = nodes[nid];
|
|
702
847
|
if (!node || node.kind !== 'call') continue;
|
|
@@ -705,16 +850,24 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
705
850
|
if (!sink) continue;
|
|
706
851
|
const inS = inStates.get(nid) || new Set();
|
|
707
852
|
if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
|
|
708
|
-
const argRefsImplicit = (node.args || []).some((a) => {
|
|
709
|
-
const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
|
|
710
|
-
});
|
|
711
853
|
const allConst = (node.args || []).length > 0 && (node.args || []).every((a) => a && a.kind === 'literal');
|
|
712
|
-
if (
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
854
|
+
if (allConst) reportImplicit(nid, node, sink, ctx.conditionLabel);
|
|
855
|
+
}
|
|
856
|
+
// Pass 2 — any sink call anywhere in the function whose argument
|
|
857
|
+
// reads an implicit-tainted variable, regardless of whether the
|
|
858
|
+
// call site itself is inside a branch.
|
|
859
|
+
if (implicitState.size) {
|
|
860
|
+
for (const [nid, node] of Object.entries(nodes)) {
|
|
861
|
+
if (!node || node.kind !== 'call') continue;
|
|
862
|
+
const cat = matchSinkOrSanitizer(node.callee, _currentFile);
|
|
863
|
+
const sink = cat && cat.find((e) => e.kind === 'sink');
|
|
864
|
+
if (!sink) continue;
|
|
865
|
+
const inS = inStates.get(nid) || new Set();
|
|
866
|
+
if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
|
|
867
|
+
const argRefsImplicit = (node.args || []).some((a) => {
|
|
868
|
+
const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
|
|
717
869
|
});
|
|
870
|
+
if (argRefsImplicit) reportImplicit(nid, node, sink, ictx.get(nid)?.conditionLabel || null);
|
|
718
871
|
}
|
|
719
872
|
}
|
|
720
873
|
} catch { /* implicit flow is best-effort + opt-in */ }
|
|
@@ -785,9 +938,13 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
785
938
|
// unbounded blowup). v0.66 — the inner ctx now records mutatedParams
|
|
786
939
|
// via _mutatedParamsOut so cross-function param mutation propagates.
|
|
787
940
|
const MAX_FP_ITERS = 3;
|
|
788
|
-
let prevCacheSize = -1;
|
|
789
941
|
for (let it = 0; it < MAX_FP_ITERS; it++) {
|
|
790
942
|
if (Date.now() > deadlineMs) break;
|
|
943
|
+
// Tracks whether this iteration actually changed any cached summary's
|
|
944
|
+
// VALUE — the correct convergence signal (see below). Reset each
|
|
945
|
+
// iteration; if nothing changed, the fixed point has been reached and
|
|
946
|
+
// further iterations would recompute byte-identical results.
|
|
947
|
+
let changedThisIter = false;
|
|
791
948
|
for (const fn of fnList) {
|
|
792
949
|
if (Date.now() > deadlineMs) break;
|
|
793
950
|
const entry = new Set();
|
|
@@ -802,20 +959,44 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
802
959
|
_mutatedParamsOut: new Set(),
|
|
803
960
|
};
|
|
804
961
|
try { analyzeFunction(fn, entry, ctx); } catch {}
|
|
962
|
+
// Report real findings discovered by this probe rather than letting
|
|
963
|
+
// them die with `ctx` — see _collectFindings's header comment. Safe to
|
|
964
|
+
// call every iteration and again from the main loop below: dedup is by
|
|
965
|
+
// (sinkId, file, line), so re-discovering the same empty-entry finding
|
|
966
|
+
// multiple times collapses to one reported finding, never a duplicate.
|
|
967
|
+
_collectFindings(fn, ctx._findings);
|
|
805
968
|
const next = {
|
|
806
969
|
returnTainted: !!ctx._returnTainted,
|
|
807
970
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
808
971
|
taintedGlobals: new Set(),
|
|
809
972
|
findings: [],
|
|
810
973
|
};
|
|
974
|
+
// Membership-aware, not size-only — two summaries with the same
|
|
975
|
+
// mutatedParams CARDINALITY but different MEMBERS (e.g. {'a'} vs
|
|
976
|
+
// {'b'}) were treated as unchanged, so a real refinement across
|
|
977
|
+
// iterations (a callee's mutated-field identity settling once its own
|
|
978
|
+
// callees' summaries became known) was silently never written to the
|
|
979
|
+
// cache. Same bug class as summaries.js's _summaryEq, fixed alongside
|
|
980
|
+
// it. Reuses the same access-path-aware setsEqual already imported
|
|
981
|
+
// for taint-state comparison elsewhere in this file (mutatedParams
|
|
982
|
+
// entries are access paths too, e.g. '_this_.field').
|
|
811
983
|
if (!existing
|
|
812
984
|
|| existing.returnTainted !== next.returnTainted
|
|
813
|
-
|| (existing.mutatedParams
|
|
985
|
+
|| !accessSetsEqual(existing.mutatedParams, next.mutatedParams)) {
|
|
814
986
|
summaryCache.set(fn.qid, entry, next);
|
|
987
|
+
changedThisIter = true;
|
|
815
988
|
}
|
|
816
989
|
}
|
|
817
|
-
|
|
818
|
-
|
|
990
|
+
// NOT `summaryCache.size() === prevCacheSize` (the pre-fix check): every
|
|
991
|
+
// function gets a cache key on iteration 0 (`!existing` is true for all
|
|
992
|
+
// of them), so `.size()` — a KEY COUNT — jumps from 0 to N once and then
|
|
993
|
+
// never changes again, since overwriting an existing Map key never
|
|
994
|
+
// changes `.size`. That made the loop `break` after iteration 1
|
|
995
|
+
// regardless of whether iteration 1 itself found real refinements to
|
|
996
|
+
// write, silently delivering 2 rounds of fixed-point refinement instead
|
|
997
|
+
// of the MAX_FP_ITERS=3 this code and dataflow/CLAUDE.md both promise.
|
|
998
|
+
// `changedThisIter` tracks actual value changes instead.
|
|
999
|
+
if (!changedThisIter) break;
|
|
819
1000
|
}
|
|
820
1001
|
// Class-field cross-taint pass: when a method writes tainted data to _this_.field,
|
|
821
1002
|
// re-analyze other methods of the same class with those fields in the entry state.
|
|
@@ -844,11 +1025,19 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
844
1025
|
_mutatedParamsOut: new Set(),
|
|
845
1026
|
};
|
|
846
1027
|
try { analyzeFunction(fn, fields, ctx); } catch {}
|
|
1028
|
+
// `findings` carries the REAL findings from this probe (was hardcoded
|
|
1029
|
+
// `[]`, discarding them) — but this pass is speculative (every field
|
|
1030
|
+
// in `fields` is assumed simultaneously tainted; nothing here confirms
|
|
1031
|
+
// this exact method is ever reached with that state), so it must not
|
|
1032
|
+
// report them itself. They ride on the cached summary and are only
|
|
1033
|
+
// surfaced by _mergeSummaryFindings when a REAL call site (assign,
|
|
1034
|
+
// plain-call, or higher-order) actually consults this qid+entry —
|
|
1035
|
+
// at that point a genuine reachable caller has been established.
|
|
847
1036
|
summaryCache.set(fn.qid, fields, {
|
|
848
1037
|
returnTainted: !!ctx._returnTainted,
|
|
849
1038
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
850
1039
|
taintedGlobals: new Set(),
|
|
851
|
-
findings:
|
|
1040
|
+
findings: ctx._findings,
|
|
852
1041
|
});
|
|
853
1042
|
}
|
|
854
1043
|
}
|
|
@@ -868,11 +1057,27 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
868
1057
|
_mutatedParamsOut: new Set(),
|
|
869
1058
|
};
|
|
870
1059
|
try { analyzeFunction(fn, taintedEntry, ctx); } catch {}
|
|
1060
|
+
// `findings` carries the real findings from this probe (was hardcoded
|
|
1061
|
+
// `[]`). This pass assumes EVERY param is simultaneously tainted —
|
|
1062
|
+
// there's no check that any real caller ever passes tainted data here
|
|
1063
|
+
// at all (the header comment above claims "AND at least one caller in
|
|
1064
|
+
// the call graph"; the code has never actually enforced that) — so
|
|
1065
|
+
// these findings must not be reported unconditionally, only when a real
|
|
1066
|
+
// call site's own entry state happens to match and consults this cached
|
|
1067
|
+
// summary via _mergeSummaryFindings. This is the exact scenario that
|
|
1068
|
+
// motivated storing them at all: an inline callback
|
|
1069
|
+
// (`arr.forEach(x => sink(x))`) has one param, so it gets probed here
|
|
1070
|
+
// with taintedEntry={param} BEFORE the higher-order invocation loop
|
|
1071
|
+
// below ever runs; without `findings` riding on the cached summary, the
|
|
1072
|
+
// real finding computed right here was thrown away and unrecoverable —
|
|
1073
|
+
// the higher-order loop's own `summaryCache.get()` would hit this
|
|
1074
|
+
// now-cached (finding-less) summary and never call `compute()` (the
|
|
1075
|
+
// only place that used to merge findings) at all.
|
|
871
1076
|
summaryCache.set(fn.qid, taintedEntry, {
|
|
872
1077
|
returnTainted: !!ctx._returnTainted,
|
|
873
1078
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
874
1079
|
taintedGlobals: new Set(),
|
|
875
|
-
findings:
|
|
1080
|
+
findings: ctx._findings,
|
|
876
1081
|
});
|
|
877
1082
|
}
|
|
878
1083
|
for (const fn of fnList) {
|
|
@@ -899,13 +1104,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
899
1104
|
for (let hi = 0; hi < Math.min(hoInvocations.length, HO_CAP); hi++) {
|
|
900
1105
|
if (Date.now() > deadlineMs) break;
|
|
901
1106
|
const inv = hoInvocations[hi];
|
|
902
|
-
if (!inv.callee || !inv.taintedParam) continue;
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
1107
|
+
if ((!inv.callee && !inv.calleeQid) || !inv.taintedParam) continue;
|
|
1108
|
+
// Two resolution strategies, matching the two shapes the push site can
|
|
1109
|
+
// record: an inline callback (`arr.map(x => ...)`) carries an exact
|
|
1110
|
+
// qid (parser-js.js's exprOf synthesizes it identically to how
|
|
1111
|
+
// enterFn will independently name the same node) — look it up directly
|
|
1112
|
+
// in callGraph.functions, no name resolution involved. A by-reference
|
|
1113
|
+
// callback (`arr.map(processItem)`) carries a bare ident name; resolve
|
|
1114
|
+
// it the same way every other call-graph lookup in this file does.
|
|
1115
|
+
// resolveKnownCallee (never the bare-tail-guessing resolve()) since a
|
|
1116
|
+
// wrong guess here would fabricate a callback relationship that
|
|
1117
|
+
// doesn't exist.
|
|
1118
|
+
const cbFn = inv.calleeQid
|
|
1119
|
+
? functionRecord(callGraph, inv.calleeQid)
|
|
1120
|
+
: functionRecord(callGraph, callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(inv.callee, fn && fn.file) : null);
|
|
909
1121
|
if (!cbFn || !cbFn.params || !cbFn.params.length) continue;
|
|
910
1122
|
const cbEntry = new Set([cbFn.params[inv.paramIndex || 0]]);
|
|
911
1123
|
let cbSummary = summaryCache.get(cbFn.qid, cbEntry);
|
|
@@ -918,21 +1130,57 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
918
1130
|
_mutatedParamsOut: new Set(),
|
|
919
1131
|
};
|
|
920
1132
|
try { analyzeFunction(cbFn, cbEntry, inner); } catch {}
|
|
921
|
-
// Merge any findings from the callback analysis into the caller.
|
|
922
|
-
callContext._findings.push(...inner._findings.map(f => ({ ...f, _funcQid: fn.qid, _via: 'higher-order' })));
|
|
923
1133
|
return {
|
|
924
1134
|
returnTainted: !!inner._returnTainted,
|
|
925
1135
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
926
1136
|
taintedGlobals: new Set(),
|
|
927
|
-
findings:
|
|
1137
|
+
findings: inner._findings,
|
|
928
1138
|
};
|
|
929
1139
|
});
|
|
930
1140
|
}
|
|
1141
|
+
// Uniform with the assign/plain-call sites: merge whether this was a
|
|
1142
|
+
// fresh compute() (findings from `inner` just above) or a cache HIT —
|
|
1143
|
+
// e.g. the k=2 pass already probed this exact qid+entry (a callback
|
|
1144
|
+
// with one param has taintedEntry===cbEntry) and stashed its own real
|
|
1145
|
+
// findings on the summary rather than reporting them speculatively.
|
|
1146
|
+
_mergeSummaryFindings(callContext, fn.qid, cbSummary, 'higher-order');
|
|
931
1147
|
}
|
|
932
|
-
|
|
933
|
-
|
|
1148
|
+
_collectFindings(fn, callContext._findings);
|
|
1149
|
+
}
|
|
1150
|
+
// v0.69 — expose cache to caller (runDeepAnalysis) for incremental persistence.
|
|
1151
|
+
// Dead code suppression: demote findings in functions with zero callers
|
|
1152
|
+
// (except route handlers which are entry points)
|
|
1153
|
+
const calledQids = new Set();
|
|
1154
|
+
if (callGraph.edges) for (const e of callGraph.edges) calledQids.add(typeof e.to === 'string' ? e.to : e.to?.qid);
|
|
1155
|
+
if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (callers && callers.size) calledQids.add(qid); }
|
|
1156
|
+
for (const f of all) {
|
|
1157
|
+
if (!f._funcQid) continue;
|
|
1158
|
+
const fn = callGraph.functions?.get(f._funcQid);
|
|
1159
|
+
if (!fn) continue;
|
|
1160
|
+
if (calledQids.has(f._funcQid)) continue;
|
|
1161
|
+
if (/handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
|
|
1162
|
+
f._inDeadCode = true;
|
|
1163
|
+
const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
1164
|
+
if (dg[f.severity]) f.severity = dg[f.severity];
|
|
1165
|
+
}
|
|
1166
|
+
Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
|
|
1167
|
+
return all;
|
|
1168
|
+
|
|
1169
|
+
// Dedup + map a raw findings array (from analyzeFunction's callContext)
|
|
1170
|
+
// into the reported IR-TAINT shape, attributed to `fn`. Used directly by
|
|
1171
|
+
// the main loop and the empty-entry pre-pass (both analyze under an entry
|
|
1172
|
+
// state that is either empty or true-by-construction, so their findings
|
|
1173
|
+
// are unconditionally real). The class-field and k=2 pre-passes are
|
|
1174
|
+
// speculative (they assume fields/params are tainted without confirming
|
|
1175
|
+
// any real caller ever does that) — see _mergeSummaryFindings, which is
|
|
1176
|
+
// the gate that gives their findings a chance to be reported only once a
|
|
1177
|
+
// genuine caller is established.
|
|
1178
|
+
function _collectFindings(attributedFn, srcFindings) {
|
|
1179
|
+
for (const f of srcFindings) {
|
|
1180
|
+
const key = `${f.sinkId}:${attributedFn.file}:${f.line}`;
|
|
934
1181
|
if (seen.has(key)) continue;
|
|
935
1182
|
seen.add(key);
|
|
1183
|
+
const fn = attributedFn;
|
|
936
1184
|
all.push({
|
|
937
1185
|
id: `ir-taint:${fn.file}:${f.line}:${f.sinkId}`,
|
|
938
1186
|
file: fn.file,
|
|
@@ -946,6 +1194,39 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
946
1194
|
// (a control-dependence finding, not an explicit data-flow one).
|
|
947
1195
|
confidence: (f.implicit && typeof f.confidence === 'number') ? f.confidence : 0.75,
|
|
948
1196
|
...(f.implicit === true ? { implicit: true } : {}),
|
|
1197
|
+
// Sanitizer callees observed on the value reaching this sink. This
|
|
1198
|
+
// mapping is an explicit allowlist, so a field absent here is silently
|
|
1199
|
+
// dropped — which is what previously left sanitizer-gate.js inert.
|
|
1200
|
+
...(Array.isArray(f._sanitizersOnPath) && f._sanitizersOnPath.length
|
|
1201
|
+
? { _sanitizersOnPath: f._sanitizersOnPath } : {}),
|
|
1202
|
+
// _funcQid: the enclosing function's qid, set upstream during the walk
|
|
1203
|
+
// but silently dropped by this allowlist before backward.js's
|
|
1204
|
+
// annotateBackwardSlices ever saw it — the same class of omission
|
|
1205
|
+
// _sanitizersOnPath had. Without it, annotateBackwardSlices's very
|
|
1206
|
+
// first check (`if (!f._funcQid) skip`) discarded every finding, so
|
|
1207
|
+
// backward-slice annotation was permanently a no-op regardless of
|
|
1208
|
+
// AGENTIC_SECURITY_BACKWARD_SLICE.
|
|
1209
|
+
...(f._funcQid ? { _funcQid: f._funcQid } : {}),
|
|
1210
|
+
// callee: kept as plain `callee` (not underscore-prefixed) to match
|
|
1211
|
+
// backward.js's own contract, which reads `f.callee` on both real and
|
|
1212
|
+
// fake-fixture findings throughout its module and test suite. It is
|
|
1213
|
+
// the SAME object reference as the CFG call node's own `callee` (set
|
|
1214
|
+
// at `_sinkFindingsForCall`'s call site as `callee: calleeExpr`,
|
|
1215
|
+
// never copied) — backward.js's sink-node lookup matches
|
|
1216
|
+
// `n.callee === f.callee` by reference identity, so dropping this
|
|
1217
|
+
// field (as the allowlist previously did) meant that match could
|
|
1218
|
+
// never succeed regardless of `_funcQid`. It is not read by
|
|
1219
|
+
// report/index.js's normalizeFindings, which is itself an explicit
|
|
1220
|
+
// allowlist that never names `callee`, so this does not reach SARIF/
|
|
1221
|
+
// JSON/HTML report output.
|
|
1222
|
+
...(f.callee !== undefined ? { callee: f.callee } : {}),
|
|
1223
|
+
// sourceProvenance/chain[].provenance: catalog.js's per-source label
|
|
1224
|
+
// (e.g. 'http-body' for req.body) computed earlier at the finding's
|
|
1225
|
+
// creation site — previously dropped by this allowlist, which is
|
|
1226
|
+
// what left posture/exploitability-probability.js's
|
|
1227
|
+
// 'source-from-network' factor permanently dead (it reads
|
|
1228
|
+
// t.provenance off chain/trace entries).
|
|
1229
|
+
sourceProvenance: f.sourceProvenance || null,
|
|
949
1230
|
source: f.trace && f.trace.length ? {
|
|
950
1231
|
file: fn.file,
|
|
951
1232
|
line: f.trace[0].line,
|
|
@@ -957,27 +1238,10 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
957
1238
|
label: f.sinkId,
|
|
958
1239
|
},
|
|
959
1240
|
chain: (f.trace || []).map(t => ({
|
|
960
|
-
file: fn.file, line: t.line, label: t.sourceLabel,
|
|
1241
|
+
file: fn.file, line: t.line, label: t.sourceLabel, provenance: t.provenance || null,
|
|
961
1242
|
})),
|
|
962
1243
|
});
|
|
963
1244
|
}
|
|
964
1245
|
}
|
|
965
|
-
|
|
966
|
-
// Dead code suppression: demote findings in functions with zero callers
|
|
967
|
-
// (except route handlers which are entry points)
|
|
968
|
-
const calledQids = new Set();
|
|
969
|
-
if (callGraph.edges) for (const e of callGraph.edges) calledQids.add(typeof e.to === 'string' ? e.to : e.to?.qid);
|
|
970
|
-
if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (callers && callers.size) calledQids.add(qid); }
|
|
971
|
-
for (const f of all) {
|
|
972
|
-
if (!f._funcQid) continue;
|
|
973
|
-
const fn = callGraph.functions?.get(f._funcQid);
|
|
974
|
-
if (!fn) continue;
|
|
975
|
-
if (calledQids.has(f._funcQid)) continue;
|
|
976
|
-
if (/handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
|
|
977
|
-
f._inDeadCode = true;
|
|
978
|
-
const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
979
|
-
if (dg[f.severity]) f.severity = dg[f.severity];
|
|
980
|
-
}
|
|
981
|
-
Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
|
|
982
|
-
return all;
|
|
1246
|
+
|
|
983
1247
|
}
|
|
@@ -24,6 +24,7 @@ import * as fs from 'node:fs';
|
|
|
24
24
|
import * as path from 'node:path';
|
|
25
25
|
import * as crypto from 'node:crypto';
|
|
26
26
|
|
|
27
|
+
import { statePath, safeWriteState, stateWritesEnabled } from '../posture/state-dir.js';
|
|
27
28
|
// ── Per-call-site refined summaries ────────────────────────────────────────
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -160,7 +161,7 @@ export function backwardSlice(callGraph, finding, opts = {}) {
|
|
|
160
161
|
// ── Persistent cross-scan summary cache ────────────────────────────────────
|
|
161
162
|
|
|
162
163
|
function _cachePath(scanRoot) {
|
|
163
|
-
return
|
|
164
|
+
return statePath(scanRoot, 'ifds-summaries.json');
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
function _fileHash(content) {
|
|
@@ -190,8 +191,9 @@ export function loadPersistedCache(scanRoot) {
|
|
|
190
191
|
* skip re-analysis of functions whose file hash hasn't changed.
|
|
191
192
|
*/
|
|
192
193
|
export function persistCache(scanRoot, cache, perFileIR) {
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
// A read-only scan still ANALYSES; it just may not persist the cache. The
|
|
195
|
+
// next scan simply recomputes, which is slower and identical. (PRD M1)
|
|
196
|
+
if (!stateWritesEnabled()) return;
|
|
195
197
|
const fileHashes = {};
|
|
196
198
|
for (const [filePath, ir] of (perFileIR || new Map())) {
|
|
197
199
|
if (ir && typeof ir._content === 'string') fileHashes[filePath] = _fileHash(ir._content);
|
|
@@ -203,7 +205,7 @@ export function persistCache(scanRoot, cache, perFileIR) {
|
|
|
203
205
|
if (sum.has('∅')) summaries[qid] = sum.get('∅');
|
|
204
206
|
}
|
|
205
207
|
const out = { scanTs: new Date().toISOString(), summaries, fileHashes };
|
|
206
|
-
try {
|
|
208
|
+
try { safeWriteState(_cachePath(scanRoot), JSON.stringify(out, null, 2)); }
|
|
207
209
|
catch { /* best-effort */ }
|
|
208
210
|
}
|
|
209
211
|
|