@clear-capabilities/agentic-security-scanner 0.78.0 → 0.79.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 (76) hide show
  1. package/bin/.agentic-security/findings.json +16 -16
  2. package/bin/.agentic-security/last-scan.json +16 -16
  3. package/bin/.agentic-security/last-scan.json.sig +1 -1
  4. package/bin/.agentic-security/scan-history.json +51 -0
  5. package/bin/.agentic-security/streak.json +5 -5
  6. package/bin/agentic-security.js +22 -7
  7. package/dist/178.index.js +1 -1
  8. package/dist/384.index.js +1 -1
  9. package/dist/476.index.js +5 -5
  10. package/dist/637.index.js +1 -1
  11. package/dist/700.index.js +138 -0
  12. package/dist/718.index.js +53 -0
  13. package/dist/838.index.js +1 -1
  14. package/dist/985.index.js +5 -0
  15. package/dist/agentic-security.mjs +1 -1
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +2 -2
  18. package/src/dataflow/engine.js +52 -8
  19. package/src/engine.js +107 -6
  20. package/src/integrations/index.js +2 -1
  21. package/src/ir/callgraph.js +27 -7
  22. package/src/llm-validator/index.js +7 -5
  23. package/src/mcp/audit.js +5 -0
  24. package/src/posture/calibration-drift.js +2 -1
  25. package/src/posture/calibration.js +3 -2
  26. package/src/posture/fix-history.js +8 -2
  27. package/src/posture/profile.js +4 -5
  28. package/src/posture/rule-overrides.js +2 -3
  29. package/src/posture/rule-pack-signing.js +2 -3
  30. package/src/posture/rule-synthesis.js +5 -6
  31. package/src/posture/security-trend.js +4 -7
  32. package/src/posture/state-dir.js +124 -0
  33. package/src/posture/streak.js +3 -0
  34. package/src/posture/suppressions.js +5 -8
  35. package/src/posture/triage.js +3 -5
  36. package/src/posture/validator-metrics.js +3 -6
  37. package/src/sast/db-taint.js +24 -0
  38. package/src/sast/rust.js +26 -0
  39. package/src/sca/binary-metadata.js +124 -0
  40. package/src/sca/py-package-functions.js +118 -0
  41. package/src/sca/vendor-detect.js +53 -0
  42. package/src/.agentic-security/findings.json +0 -82642
  43. package/src/.agentic-security/last-scan.json +0 -82642
  44. package/src/.agentic-security/last-scan.json.sig +0 -1
  45. package/src/.agentic-security/scan-history.json +0 -10054
  46. package/src/.agentic-security/streak.json +0 -21
  47. package/src/dataflow/.agentic-security/findings.json +0 -3515
  48. package/src/dataflow/.agentic-security/last-scan.json +0 -3515
  49. package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
  50. package/src/dataflow/.agentic-security/scan-history.json +0 -702
  51. package/src/dataflow/.agentic-security/streak.json +0 -22
  52. package/src/ir/.agentic-security/findings.json +0 -3777
  53. package/src/ir/.agentic-security/last-scan.json +0 -3777
  54. package/src/ir/.agentic-security/last-scan.json.sig +0 -1
  55. package/src/ir/.agentic-security/scan-history.json +0 -771
  56. package/src/ir/.agentic-security/streak.json +0 -21
  57. package/src/posture/.agentic-security/findings.json +0 -51562
  58. package/src/posture/.agentic-security/last-scan.json +0 -51562
  59. package/src/posture/.agentic-security/last-scan.json.sig +0 -1
  60. package/src/posture/.agentic-security/scan-history.json +0 -650
  61. package/src/posture/.agentic-security/streak.json +0 -20
  62. package/src/report/.agentic-security/findings.json +0 -80
  63. package/src/report/.agentic-security/last-scan.json +0 -80
  64. package/src/report/.agentic-security/last-scan.json.sig +0 -1
  65. package/src/report/.agentic-security/scan-history.json +0 -35
  66. package/src/report/.agentic-security/streak.json +0 -22
  67. package/src/sast/.agentic-security/findings.json +0 -5190
  68. package/src/sast/.agentic-security/last-scan.json +0 -5190
  69. package/src/sast/.agentic-security/last-scan.json.sig +0 -1
  70. package/src/sast/.agentic-security/scan-history.json +0 -408
  71. package/src/sast/.agentic-security/streak.json +0 -20
  72. package/src/sca/.agentic-security/findings.json +0 -1587
  73. package/src/sca/.agentic-security/last-scan.json +0 -1587
  74. package/src/sca/.agentic-security/last-scan.json.sig +0 -1
  75. package/src/sca/.agentic-security/scan-history.json +0 -36
  76. package/src/sca/.agentic-security/streak.json +0 -21
@@ -1 +1 @@
1
- 25827a67ed38d68ccda93ff583b328939807335db8d019da0516c1e62eda5a98 agentic-security.mjs
1
+ 3ec7e435269654d09e2168e1cd8a6586f88b9c2edce86f48c18a2dc5f321e358 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin \u2014 SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, SARIF ingest, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -55,7 +55,7 @@
55
55
  "test": "npm run test:smoke && npm run test:sast && npm run test:posture && npm run test:dataflow && npm run test:mcp && npm run test:report && npm run test:bench-modules && npm run test:lifecycle && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
56
56
  "test:smoke": "node --test test/smoke.test.js",
57
57
  "test:sast": "node --test test/llm.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/redos-nfa.test.js test/weak-randomness.test.js",
58
- "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js",
58
+ "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js",
59
59
  "test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js",
60
60
  "test:mcp": "node --test test/mcp.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js",
61
61
  "test:report": "node --test test/sarif-ingest.test.js test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js",
@@ -62,13 +62,13 @@ function _addPathAliasAware(state, path, callContext) {
62
62
  return s;
63
63
  }
64
64
 
65
+ let _activeConstantVars = null;
66
+
65
67
  function exprTaint(expr, state) {
66
- // Returns true iff this expression evaluates to a tainted value under the
67
- // given taint state. ALSO treats catalog-registered source patterns as
68
- // tainted at-read — `req.body.host` used inline (no intermediate local)
69
- // is tainted because the source resolves at the read site.
70
68
  if (expr && expr.kind === 'member' && exprIsSource(expr)) return true;
71
69
  if (!expr) return false;
70
+ // Constant propagation: variables assigned from literals are never tainted
71
+ if (expr.kind === 'ident' && _activeConstantVars && _activeConstantVars.has(expr.name)) return false;
72
72
  // P1.1 — field-sensitive access path: if the expression is a pure
73
73
  // ident/member chain ("x.y.z"), ask the access-path lattice whether any
74
74
  // shorter prefix in the state covers it. This is what makes
@@ -157,13 +157,35 @@ function exprIsSource(expr) {
157
157
  const hit = matchSource(expr);
158
158
  if (hit) return hit;
159
159
  }
160
- // Recurse — `req.body.name` should still find `req.body` as source.
161
160
  if (expr.kind === 'member' && expr.object) {
162
161
  return exprIsSource(expr.object);
163
162
  }
164
163
  return null;
165
164
  }
166
165
 
166
+ const _SQL_KEYWORDS = /\b(SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|UNION|WHERE|FROM|JOIN|INTO|VALUES|SET|EXEC|EXECUTE)\b/i;
167
+ const _HTML_META = /[<>'"&]|innerHTML|outerHTML|document\.write/;
168
+ const _SHELL_META = /[;|`$(){}]|&&|\|\|/;
169
+
170
+ function _literalPartsOfExpr(expr) {
171
+ if (!expr) return [];
172
+ if (expr.kind === 'literal') return [String(expr.value || '')];
173
+ if (expr.kind === 'tpl') return (expr.parts || []).filter(p => p.kind === 'literal').map(p => String(p.value || ''));
174
+ if (expr.kind === 'binary') return [..._literalPartsOfExpr(expr.left), ..._literalPartsOfExpr(expr.right)];
175
+ return [];
176
+ }
177
+
178
+ function literalSkeletonMatchesFamily(expr, cwe) {
179
+ const literals = _literalPartsOfExpr(expr);
180
+ if (!literals.length) return true;
181
+ const joined = literals.join(' ');
182
+ if (!joined.trim()) return true;
183
+ if (cwe === 'CWE-89' || cwe === 'CWE-943') return _SQL_KEYWORDS.test(joined);
184
+ if (cwe === 'CWE-79') return _HTML_META.test(joined);
185
+ if (cwe === 'CWE-78') return _SHELL_META.test(joined);
186
+ return true;
187
+ }
188
+
167
189
  // Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
168
190
  function step(node, stateIn, callContext) {
169
191
  const state = new Set(stateIn);
@@ -177,9 +199,13 @@ function step(node, stateIn, callContext) {
177
199
  return { state, findings };
178
200
 
179
201
  case 'assign': {
180
- // Source detection on RHS.
181
202
  const src = exprIsSource(node.source);
182
203
  const target = typeof node.target === 'string' ? node.target : null;
204
+ // Constant propagation: track variables assigned from literals
205
+ if (target && _activeConstantVars) {
206
+ if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
207
+ else _activeConstantVars.delete(target);
208
+ }
183
209
  let newState = state;
184
210
  // Premortem #7: interprocedural return-taint via SummaryCache. If the
185
211
  // RHS is a call to a known callee whose empty-entry-state summary says
@@ -340,6 +366,8 @@ function step(node, stateIn, callContext) {
340
366
  const taintedArgIdx = e.argIndex === 'all'
341
367
  ? argTaints.findIndex(Boolean) : e.argIndex;
342
368
  const taintedArgExpr = (node.args || [])[taintedArgIdx];
369
+ // String content analysis: skip if literal skeleton doesn't match injection family
370
+ if (e.vuln && taintedArgExpr && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
343
371
  // Premortem #10: attribute the source for THIS sink to the
344
372
  // source(s) that taint the actual argument expression — not the
345
373
  // first source the worklist happened to record. We walk the
@@ -438,12 +466,13 @@ function step(node, stateIn, callContext) {
438
466
  // every 100 iterations. A pathological CFG (large generated file with dense
439
467
  // control flow) can otherwise hold past the global timeout.
440
468
  function analyzeFunction(fn, entryState, callContext) {
441
- const nodes = fn.cfg.nodes; // plain object
469
+ const nodes = fn.cfg.nodes;
442
470
  const work = [];
443
- const inStates = new Map(); // nodeId → Set<varName>
471
+ const inStates = new Map();
444
472
  const outStates = new Map();
445
473
  inStates.set(fn.cfg.entry, new Set(entryState));
446
474
  work.push(fn.cfg.entry);
475
+ _activeConstantVars = new Map();
447
476
  // v0.70 #2 — points-to context for the step() transfer. Setting it here
448
477
  // (instead of plumbing through step's signature) keeps the worklist loop
449
478
  // unchanged and lets `step` consult `aliasesForVar` when callContext._pointsTo
@@ -712,6 +741,21 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
712
741
  }
713
742
  }
714
743
  // v0.69 — expose cache to caller (runDeepAnalysis) for incremental persistence.
744
+ // Dead code suppression: demote findings in functions with zero callers
745
+ // (except route handlers which are entry points)
746
+ const calledQids = new Set();
747
+ if (callGraph.edges) for (const e of callGraph.edges) calledQids.add(typeof e.to === 'string' ? e.to : e.to?.qid);
748
+ if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (callers && callers.size) calledQids.add(qid); }
749
+ for (const f of all) {
750
+ if (!f._funcQid) continue;
751
+ const fn = callGraph.functions?.get(f._funcQid);
752
+ if (!fn) continue;
753
+ if (calledQids.has(f._funcQid)) continue;
754
+ if (/handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
755
+ f._inDeadCode = true;
756
+ const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
757
+ if (dg[f.severity]) f.severity = dg[f.severity];
758
+ }
715
759
  Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
716
760
  return all;
717
761
  }
package/src/engine.js CHANGED
@@ -55,7 +55,7 @@ import { scanCpp } from './sast/cpp.js';
55
55
  import { scanJulietShape, applyJulietJavaSuppressions, applyJulietCsSuppressions } from './sast/juliet-shape.js';
56
56
  import { scanCppDataflow, _parseErrorCount as _cppDataflowParseErrors } from './sast/cpp-dataflow.js';
57
57
  import { scanSolidity } from './sast/solidity.js';
58
- import { scanRust } from './sast/rust.js';
58
+ import { scanRust, extractRustImportMap } from './sast/rust.js';
59
59
  import { scanGoExtended } from './sast/go-extended.js';
60
60
  import { scanDatabaseRLS } from './sast/db-rls.js';
61
61
  import { scanRateLimit } from './sast/rate-limit.js';
@@ -4563,10 +4563,14 @@ function scanMiddlewareOrdering(fp, raw){
4563
4563
  if (isAuth && line < firstAuthAt) firstAuthAt = line;
4564
4564
  if (mountPath) mounts.push({ line, mountPath, handlerArgs, isAuth });
4565
4565
  }
4566
+ let firstRateLimitAt = Infinity;
4567
+ for (const mt of mounts) {
4568
+ if (/rateLimit|rate.?limit|throttle|slowDown/i.test(mt.handlerArgs) && mt.line < firstRateLimitAt) firstRateLimitAt = mt.line;
4569
+ }
4566
4570
  for (const mt of mounts) {
4567
4571
  if (mt.isAuth) continue;
4568
4572
  if (!_SENSITIVE_PATH_RE.test(mt.mountPath)) continue;
4569
- if (mt.line >= firstAuthAt) continue; // mounted after auth — fine
4573
+ if (mt.line >= firstAuthAt) continue;
4570
4574
  findings.push({
4571
4575
  vuln: `Sensitive Route Mounted Before Auth Middleware (${mt.mountPath})`,
4572
4576
  severity: 'high', cwe: 'CWE-285', stride: 'Elevation of Privilege',
@@ -4574,6 +4578,27 @@ function scanMiddlewareOrdering(fp, raw){
4574
4578
  fix: `Register your auth middleware before mounting ${mt.mountPath}. Either move app.use(authMiddleware) above this line, or pass authMiddleware directly: app.use('${mt.mountPath}', authMiddleware, router).`,
4575
4579
  });
4576
4580
  }
4581
+ // Check rate-limiting before auth (auth endpoints should be rate-limited)
4582
+ if (firstAuthAt < Infinity && firstRateLimitAt > firstAuthAt) {
4583
+ findings.push({
4584
+ vuln: 'Rate Limiting After Auth Middleware — brute-force attacks bypass rate limits',
4585
+ severity: 'medium', cwe: 'CWE-307', stride: 'Denial of Service',
4586
+ file: fp, line: firstAuthAt, snippet: lines[firstAuthAt - 1]?.trim() || '',
4587
+ fix: 'Register rate-limiting middleware BEFORE auth middleware so brute-force login attempts are throttled: app.use(rateLimit({...})); app.use(authMiddleware);',
4588
+ });
4589
+ }
4590
+ // Check for method-level auth on sensitive routes
4591
+ const routeRe = /\b(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]*(?:admin|user|account|settings|profile|payment|billing|dashboard)[^'"]*)['"]\s*,\s*(?!.*(?:auth|protect|verify|guard))/gi;
4592
+ for (const rm of cleaned.matchAll(routeRe)) {
4593
+ const routeLine = lineAt(cleaned, rm.index);
4594
+ if (routeLine >= firstAuthAt) continue;
4595
+ findings.push({
4596
+ vuln: `Sensitive Route Without Auth — ${rm[1].toUpperCase()} ${rm[2]}`,
4597
+ severity: 'high', cwe: 'CWE-306', stride: 'Elevation of Privilege',
4598
+ file: fp, line: routeLine, snippet: lines[routeLine - 1]?.trim() || '',
4599
+ fix: `Add auth middleware to this route: router.${rm[1]}('${rm[2]}', authMiddleware, handler). Or ensure app.use(authMiddleware) appears before this route definition.`,
4600
+ });
4601
+ }
4577
4602
  return findings;
4578
4603
  }
4579
4604
 
@@ -5507,17 +5532,50 @@ const VULN_FUNCTION_HINTS = {
5507
5532
  ...(typeof _VULN_FUNCTION_HINTS_GENERATED === 'object' && !Array.isArray(_VULN_FUNCTION_HINTS_GENERATED) ? Object.fromEntries(Object.entries(_VULN_FUNCTION_HINTS_GENERATED).filter(([k])=>!k.startsWith('_'))) : {}),
5508
5533
  ...(typeof _VULN_FUNCTION_HINTS_DATA === 'object' && !Array.isArray(_VULN_FUNCTION_HINTS_DATA) ? Object.fromEntries(Object.entries(_VULN_FUNCTION_HINTS_DATA).filter(([k])=>!k.startsWith('_'))) : {}),
5509
5534
  };
5535
+ function _semverSatisfies(ver,range){
5536
+ if(!ver||!range)return false;
5537
+ const parse=v=>{const m=(v||'').match(/^[=v]*(\d+)\.(\d+)\.(\d+)/);return m?[+m[1],+m[2],+m[3]]:null;};
5538
+ const cmp=(a,b)=>{for(let i=0;i<3;i++){if(a[i]!==b[i])return a[i]-b[i];}return 0;};
5539
+ const v=parse(ver);if(!v)return false;
5540
+ for(const part of range.split(/\s*\|\|\s*/)){
5541
+ let ok=true;
5542
+ for(const cond of part.split(/\s+/).filter(Boolean)){
5543
+ const m=cond.match(/^([<>=!]+)(.+)$/);if(!m)continue;
5544
+ const t=parse(m[2]);if(!t){ok=false;break;}
5545
+ const c=cmp(v,t);const op=m[1];
5546
+ if(op==='>='&&c<0){ok=false;break;}
5547
+ if(op==='>'&&c<=0){ok=false;break;}
5548
+ if(op==='<='&&c>0){ok=false;break;}
5549
+ if(op==='<'&&c>=0){ok=false;break;}
5550
+ if(op==='='&&c!==0){ok=false;break;}
5551
+ if(op==='!='&&c===0){ok=false;break;}
5552
+ }
5553
+ if(ok)return true;
5554
+ }
5555
+ return false;
5556
+ }
5510
5557
  function markUsedVulnFunctions(supplyChain,fc){
5511
5558
  const used={};
5512
5559
  const perFile={};
5513
5560
  for(const[fp,content] of Object.entries(fc)){
5514
5561
  const lines=content.split('\n');
5562
+ // Rust import-aware matching: build import map for .rs files
5563
+ let _rustImports=null;
5564
+ if(/\.rs$/i.test(fp)){try{_rustImports=extractRustImportMap(content);}catch(_){}}
5515
5565
  for(const[pkg,fns] of Object.entries(VULN_FUNCTION_HINTS)){
5516
5566
  if(!perFile[pkg])perFile[pkg]=[];
5517
5567
  for(const fn of fns){
5518
5568
  const re=new RegExp(`\\b(?:${pkg.replace(/\W/g,'\\$&')}|_)\\.${fn}\\b`,'g');
5569
+ // Rust: also match bare function calls if import map traces them to this package
5570
+ const rustBareRe=_rustImports?new RegExp(`\\b${fn.replace(/\W/g,'\\$&')}\\s*[(<]`,'g'):null;
5519
5571
  for(let li=0;li<lines.length;li++){
5520
- if(re.test(lines[li])){
5572
+ let matched=re.test(lines[li]);
5573
+ re.lastIndex=0;
5574
+ if(!matched&&rustBareRe){
5575
+ if(rustBareRe.test(lines[li])&&(_rustImports.map.get(fn)===pkg||_rustImports.map.get(fn)===pkg.replace(/-/g,'_')||_rustImports.globs.has(pkg)||_rustImports.globs.has(pkg.replace(/-/g,'_')))){matched=true;}
5576
+ rustBareRe.lastIndex=0;
5577
+ }
5578
+ if(matched){
5521
5579
  perFile[pkg].push({pkg,fn,file:fp,line:li+1});
5522
5580
  if(!used[pkg])used[pkg]=new Set();
5523
5581
  used[pkg].add(fn);
@@ -5529,10 +5587,20 @@ function markUsedVulnFunctions(supplyChain,fc){
5529
5587
  }
5530
5588
  for(const sc of supplyChain||[]){
5531
5589
  if(sc.type!=='vulnerable_dep')continue;
5532
- // Merge hints: hardcoded → OSV ecosystem_specific → skip if none
5590
+ // Merge hints: versioned → hardcoded → OSV ecosystem_specific → skip if none
5533
5591
  const hardcoded=VULN_FUNCTION_HINTS[sc.name]||[];
5592
+ // Version-scoped hints: check pkg@range keys
5593
+ const versionedFns=[];
5594
+ for(const[hk,hv] of Object.entries(VULN_FUNCTION_HINTS)){
5595
+ if(!hk.includes('@'))continue;
5596
+ const atIdx=hk.indexOf('@');
5597
+ const hPkg=hk.slice(0,atIdx);
5598
+ const hRange=hk.slice(atIdx+1);
5599
+ if(hPkg!==sc.name)continue;
5600
+ try{if(_semverSatisfies(sc.version,hRange))versionedFns.push(...hv);}catch(_){}
5601
+ }
5534
5602
  const osvFns=Array.isArray(sc.osvVulnFunctions)?sc.osvVulnFunctions.map(f=>{const d=f.lastIndexOf('.');return d>0?f.slice(d+1):f;}):[];
5535
- const allFns=[...new Set([...hardcoded,...osvFns])];
5603
+ const allFns=[...new Set([...versionedFns,...hardcoded,...osvFns])];
5536
5604
  if(!allFns.length){sc.functionReachable='unknown';sc.noKnownCallSite=true;sc._hintSource='none';continue;}
5537
5605
  sc._hintSource=osvFns.length?(hardcoded.length?'hardcoded+osv':'osv'):'hardcoded';
5538
5606
  // Search codebase for these functions (if not already searched via VULN_FUNCTION_HINTS)
@@ -6339,8 +6407,33 @@ function _parsePubspecLock(text,filePath){
6339
6407
  }return out;
6340
6408
  }
6341
6409
 
6410
+ const _APT_TO_LIB={'libssl-dev':'openssl','libssl3':'openssl','openssl':'openssl','zlib1g-dev':'zlib','zlib1g':'zlib','libcurl4-openssl-dev':'libcurl','libcurl4':'libcurl','libxml2-dev':'libxml2','libxml2':'libxml2','libpq-dev':'postgresql','libsqlite3-dev':'sqlite','libjpeg-dev':'libjpeg','libpng-dev':'libpng','libfreetype6-dev':'freetype','libexpat1-dev':'expat','libyaml-dev':'libyaml','libffi-dev':'libffi','libgmp-dev':'gmp','libncurses-dev':'ncurses','libreadline-dev':'readline'};
6411
+ function _parseCMakeLists(text,filePath){
6412
+ const out=[];
6413
+ for(const m of text.matchAll(/find_package\s*\(\s*(\w+)(?:\s+(\d+[\d.]*))?\s*(?:REQUIRED|COMPONENTS)?/gi)){
6414
+ const name=m[1].toLowerCase();const ver=m[2]||'0.0.0';
6415
+ out.push({name,version:ver,group:'',scope:'required',purl:`pkg:generic/${name}@${ver}`,ecosystem:'system',filePath,isUnpinned:!m[2]});
6416
+ }
6417
+ return out;
6418
+ }
6419
+ function _parseConanfile(text,filePath){
6420
+ const out=[];
6421
+ for(const m of text.matchAll(/(?:requires|build_requires)\s*[=(]\s*["']([^/]+)\/([^"'@]+)/g)){
6422
+ out.push({name:m[1].toLowerCase(),version:m[2],group:'',scope:'required',purl:`pkg:generic/${m[1].toLowerCase()}@${m[2]}`,ecosystem:'system',filePath,isUnpinned:false});
6423
+ }
6424
+ return out;
6425
+ }
6426
+ function _parseVcpkgJson(text,filePath){
6427
+ const out=[];try{const d=JSON.parse(text);
6428
+ for(const dep of(d.dependencies||[])){
6429
+ const name=typeof dep==='string'?dep:dep.name;
6430
+ const ver=(typeof dep==='object'&&dep['version>='])?dep['version>=']:'0.0.0';
6431
+ if(name)out.push({name:name.toLowerCase(),version:ver,group:'',scope:'required',purl:`pkg:generic/${name.toLowerCase()}@${ver}`,ecosystem:'system',filePath,isUnpinned:ver==='0.0.0'});
6432
+ }
6433
+ }catch(_){}return out;
6434
+ }
6342
6435
  function parseManifests(allFileContents){
6343
- const PARSERS={'package.json':_parsePackageJson,'package-lock.json':_parsePackageLockJson,'yarn.lock':_parseYarnLock,'pnpm-lock.yaml':_parsePnpmLock,'requirements.txt':_parseRequirementsTxt,'pyproject.toml':_parsePyprojectToml,'poetry.lock':_parsePoetryLock,'Pipfile.lock':_parsePipfileLock,'composer.json':_parseComposerJson,'composer.lock':_parseComposerLock,'Gemfile':_parseGemfile,'Gemfile.lock':_parseGemfileLock,'go.mod':_parseGoMod,'Cargo.toml':_parseCargoToml,'Cargo.lock':_parseCargoLock,'pom.xml':_parsePomXml,'build.gradle':_parseBuildGradle,'build.gradle.kts':_parseBuildGradle,'pubspec.yaml':_parsePubspecYaml,'pubspec.lock':_parsePubspecLock};
6436
+ const PARSERS={'package.json':_parsePackageJson,'package-lock.json':_parsePackageLockJson,'yarn.lock':_parseYarnLock,'pnpm-lock.yaml':_parsePnpmLock,'requirements.txt':_parseRequirementsTxt,'pyproject.toml':_parsePyprojectToml,'poetry.lock':_parsePoetryLock,'Pipfile.lock':_parsePipfileLock,'composer.json':_parseComposerJson,'composer.lock':_parseComposerLock,'Gemfile':_parseGemfile,'Gemfile.lock':_parseGemfileLock,'go.mod':_parseGoMod,'Cargo.toml':_parseCargoToml,'Cargo.lock':_parseCargoLock,'pom.xml':_parsePomXml,'build.gradle':_parseBuildGradle,'build.gradle.kts':_parseBuildGradle,'pubspec.yaml':_parsePubspecYaml,'pubspec.lock':_parsePubspecLock,'CMakeLists.txt':_parseCMakeLists,'conanfile.txt':_parseConanfile,'vcpkg.json':_parseVcpkgJson};
6344
6437
  const out=[],seen=new Set();
6345
6438
  for(const[fp,content]of Object.entries(allFileContents)){
6346
6439
  const base=fp.split('/').pop();
@@ -6931,6 +7024,8 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
6931
7024
  // 0.10.0: enrich SCA findings with CISA KEV (CISA KEV catalog)
6932
7025
  try{supplyChain=await _enrichWithKEV(supplyChain);}catch(_){}
6933
7026
  try{markUsedVulnFunctions(supplyChain,fc);}catch(_){}
7027
+ // Python AST-based function validation (deep mode only)
7028
+ if(process.env.AGENTIC_SECURITY_DEEP==='1'){try{const{validateOsvFunctionsExist}=await import('./sca/py-package-functions.js');for(const sc of supplyChain){if(sc.type!=='vulnerable_dep'||sc.ecosystem!=='pypi')continue;if(!sc.osvVulnFunctions||!sc.osvVulnFunctions.length)continue;const{validated,missing}=validateOsvFunctionsExist(sc.name,sc.osvVulnFunctions,scanRoot);if(validated.length)sc._pyAstValidated=validated;if(missing.length)sc._pyAstMissing=missing;}}catch(_){}}
6934
7029
  // LLM-assisted function extraction for CVEs without hints (opt-in: AGENTIC_SECURITY_LLM_SCA=1)
6935
7030
  if(process.env.AGENTIC_SECURITY_LLM_SCA==='1'){try{const{extractVulnFunctionsViaLLM}=await import('./sca/llm-function-extract.js');const enriched=await extractVulnFunctionsViaLLM(supplyChain);if(enriched.length){markUsedVulnFunctions(supplyChain,fc);}}catch(_){}}
6936
7031
  setProgress({current:i,total:files.length,file:"Registry metadata...",phase:"SCA"});
@@ -7457,6 +7552,12 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7457
7552
  // v3 next-gen: why-fired provenance is captured LAST so it reflects the
7458
7553
  // final state of each finding after every other annotator has run.
7459
7554
  _runAnnotator("annotateWhyFired", () => { annotateWhyFired(finalFindings, {}); });
7555
+ // SCA-SAST correlation: link SAST findings to SCA vulnerable packages
7556
+ try{for(const f of finalFindings){if(!f.chain||!f.chain.length)continue;const src=f.chain[0]?.label||'';for(const sc of supplyChain){if(sc.type!=='vulnerable_dep')continue;if(src.includes(sc.name)||f.vuln?.toLowerCase().includes(sc.name)){f.scaCorrelation={osvId:sc.osvId,package:sc.name,version:sc.version,confirmed:true};sc.sastConfirmed=true;break;}}}}catch(_){}
7557
+ // Multi-sink chain detection: group findings by source variable
7558
+ try{const srcGroups=new Map();for(const f of finalFindings){const src=f.chain?.[0]?.label;if(!src)continue;if(!srcGroups.has(src))srcGroups.set(src,[]);srcGroups.get(src).push(f);}for(const[src,group]of srcGroups){if(group.length<2)continue;const groupId=`multi-sink:${src}:${group.length}`;for(const f of group)f._multiSinkGroupId=groupId;finalFindings.push({id:groupId,file:group[0].file,line:group[0].line,vuln:`Multi-Sink Taint Chain — ${src} reaches ${group.length} sinks`,severity:group.some(f=>f.severity==='critical')?'critical':'high',cwe:'CWE-20',parser:'MULTI-SINK',confidence:0.85,sinks:group.map(f=>({file:f.file,line:f.line,vuln:f.vuln})),_aggregated:true});}}catch(_){}
7559
+ // SCA transitive dedup: collapse duplicate CVEs across dep chains
7560
+ try{const osvGroups=new Map();for(const sc of supplyChain){if(sc.type!=='vulnerable_dep'||!sc.osvId)continue;if(!osvGroups.has(sc.osvId))osvGroups.set(sc.osvId,[]);osvGroups.get(sc.osvId).push(sc);}for(const[osvId,group]of osvGroups){if(group.length<=1)continue;const primary=group.find(s=>s.isDirect)||group[0];primary.dependents=group.filter(s=>s!==primary).map(s=>({name:s.name,version:s.version,depChain:s.depChain,isDirect:s.isDirect}));primary._transitiveDeduped=group.length-1;for(const dup of group){if(dup!==primary)dup._deduplicatedInto=primary.osvId;}supplyChain.splice(0,supplyChain.length,...supplyChain.filter(s=>!s._deduplicatedInto));}}catch(_){}
7460
7561
  const _scanMeta={filesScanned:files.length,filesSkipped:_filesSkipped,filesTimedOut:_filesTimedOut,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}};
7461
7562
  return{routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors};}
7462
7563
 
@@ -13,9 +13,10 @@
13
13
  import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import * as yaml from 'js-yaml';
16
+ import { statePath } from '../posture/state-dir.js';
16
17
 
17
18
  function _configPath(scanRoot) {
18
- return path.join(scanRoot || process.cwd(), '.agentic-security', 'integrations.yml');
19
+ return statePath(scanRoot, 'integrations.yml');
19
20
  }
20
21
 
21
22
  export function loadIntegrationConfig(scanRoot) {
@@ -8,11 +8,25 @@
8
8
  // 4. Anything else → unresolved; the dataflow engine treats the callee as
9
9
  // an opaque sink for taint.
10
10
 
11
- export function buildCallGraph(perFileIR) {
12
- // perFileIR is { [file]: parseJsFile output }
13
- const functions = new Map(); // qid → FunctionIR
14
- const byNameInFile = new Map(); // file → Map<name, qid>
15
- const classMethods = new Map(); // 'ClassName.method' qid
11
+ export function buildCallGraph(perFileIR, fileContents) {
12
+ const functions = new Map();
13
+ const byNameInFile = new Map();
14
+ const classMethods = new Map();
15
+ // Re-export resolution: track `export { x } from './y'` and `module.exports = require('./y')`
16
+ const reexportMap = new Map();
17
+ if (fileContents) {
18
+ for (const [file, code] of Object.entries(fileContents)) {
19
+ if (!code || typeof code !== 'string') continue;
20
+ for (const m of code.matchAll(/export\s*\{\s*([^}]+)\s*\}\s*from\s*['"]([^'"]+)['"]/g)) {
21
+ const names = m[1].split(',').map(n => n.trim().split(/\s+as\s+/));
22
+ for (const [orig, alias] of names) {
23
+ reexportMap.set(`${file}::${alias || orig}`, { sourceFile: m[2], sourceName: orig.trim() });
24
+ }
25
+ }
26
+ const cjsReexport = code.match(/module\.exports\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
27
+ if (cjsReexport) reexportMap.set(`${file}::*`, { sourceFile: cjsReexport[1], sourceName: '*' });
28
+ }
29
+ }
16
30
 
17
31
  for (const file of Object.keys(perFileIR || {})) {
18
32
  const ir = perFileIR[file];
@@ -21,7 +35,6 @@ export function buildCallGraph(perFileIR) {
21
35
  for (const fn of ir.functions) {
22
36
  functions.set(fn.qid, fn);
23
37
  byNameInFile.get(file).set(fn.name, fn.qid);
24
- // Class methods: qid carries the class name as the scope.
25
38
  const m = fn.qid.match(/::([A-Z]\w*)::(\w+)@/);
26
39
  if (m) classMethods.set(`${m[1]}.${m[2]}`, fn.qid);
27
40
  }
@@ -56,7 +69,6 @@ export function buildCallGraph(perFileIR) {
56
69
  // ClassName.method falls back).
57
70
  function resolve(name) {
58
71
  if (!name || typeof name !== 'string') return null;
59
- // Direct ident match — search every file's same-file map.
60
72
  for (const m of byNameInFile.values()) {
61
73
  if (m.has(name)) return m.get(name);
62
74
  }
@@ -67,6 +79,14 @@ export function buildCallGraph(perFileIR) {
67
79
  if (m.has(tail)) return m.get(tail);
68
80
  }
69
81
  }
82
+ // Follow re-exports: if name was re-exported from another file, resolve there
83
+ for (const [key, { sourceName }] of reexportMap) {
84
+ if (key.endsWith(`::${name}`) || (sourceName === name)) {
85
+ for (const m of byNameInFile.values()) {
86
+ if (m.has(sourceName)) return m.get(sourceName);
87
+ }
88
+ }
89
+ }
70
90
  return null;
71
91
  }
72
92
  return { functions, edges, callersOf, resolve };
@@ -44,6 +44,7 @@
44
44
  import * as fs from 'node:fs';
45
45
  import * as path from 'node:path';
46
46
  import * as crypto from 'node:crypto';
47
+ import { statePath, ensureStateDir, safeWriteState } from '../posture/state-dir.js';
47
48
 
48
49
  // Bump on every prompt change so the cache invalidates. Exported as a
49
50
  // stable public symbol (premortem 4R-15) so the validator-cache GC subcommand
@@ -98,7 +99,9 @@ function endpointConfig() {
98
99
  }
99
100
 
100
101
  function ensureCacheDir(scanRoot) {
101
- const dir = path.join(scanRoot || process.cwd(), CACHE_DIR);
102
+ const base = ensureStateDir(scanRoot);
103
+ if (!base) return null;
104
+ const dir = path.join(base, 'llm-cache');
102
105
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
103
106
  return dir;
104
107
  }
@@ -112,15 +115,14 @@ function cacheKey(finding, fileHash, modelId) {
112
115
  }
113
116
 
114
117
  function readCache(scanRoot, key) {
115
- const fp = path.join(scanRoot || process.cwd(), CACHE_DIR, key + '.json');
118
+ const fp = statePath(scanRoot, 'llm-cache', key + '.json');
116
119
  if (!fs.existsSync(fp)) return null;
117
120
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
118
121
  }
119
122
 
120
123
  function writeCache(scanRoot, key, value) {
121
- ensureCacheDir(scanRoot);
122
- const fp = path.join(scanRoot || process.cwd(), CACHE_DIR, key + '.json');
123
- try { fs.writeFileSync(fp, JSON.stringify(value, null, 2)); } catch {}
124
+ const fp = statePath(scanRoot, 'llm-cache', key + '.json');
125
+ safeWriteState(fp, JSON.stringify(value, null, 2));
124
126
  }
125
127
 
126
128
  function fileHashOf(fileContents, file) {
package/src/mcp/audit.js CHANGED
@@ -82,6 +82,11 @@ async function _postRemote(url, entry) {
82
82
  export function auditCall({ sessionRoot, tool, args, outcome, reason }) {
83
83
  if (!sessionRoot) return;
84
84
  try {
85
+ // Safety: only write audit log if sessionRoot looks like a project root
86
+ const MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'composer.json', 'Gemfile'];
87
+ let hasMarker = false;
88
+ for (const m of MARKERS) { try { if (fs.existsSync(path.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
89
+ if (!hasMarker) return;
85
90
  const dir = path.join(sessionRoot, '.agentic-security');
86
91
  fs.mkdirSync(dir, { recursive: true });
87
92
  const logFile = path.join(dir, 'mcp-audit.log');
@@ -28,13 +28,14 @@
28
28
 
29
29
  import * as fs from 'node:fs';
30
30
  import * as path from 'node:path';
31
+ import { statePath } from './state-dir.js';
31
32
 
32
33
  const DEFAULT_THRESHOLD = 0.15;
33
34
  const MIN_SAMPLE_SIZE = 10;
34
35
  const WINDOW_DAYS = 30;
35
36
 
36
37
  function loadTriageFeedback(scanRoot) {
37
- const fp = path.join(scanRoot || process.cwd(), '.agentic-security', 'triage-feedback.json');
38
+ const fp = statePath(scanRoot, 'triage-feedback.json');
38
39
  try {
39
40
  if (!fs.existsSync(fp)) return [];
40
41
  const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
@@ -27,6 +27,7 @@
27
27
 
28
28
  import * as fs from 'node:fs';
29
29
  import * as path from 'node:path';
30
+ import { statePath } from './state-dir.js';
30
31
 
31
32
  const MIN_SAMPLES_FOR_CALIBRATION = 30;
32
33
 
@@ -102,7 +103,7 @@ function _readJsonMaybe(fp) {
102
103
  // seed file. The bundled seed ships with this release; the customer file
103
104
  // overrides per-family when N is higher there.
104
105
  export function loadCalibrationHistory(scanRoot) {
105
- const customer = _readJsonMaybe(path.join(scanRoot || process.cwd(), '.agentic-security', 'validator-metrics.json')) || {};
106
+ const customer = _readJsonMaybe(statePath(scanRoot, 'validator-metrics.json')) || {};
106
107
  const seedPath = new URL('./calibration-seed.json', import.meta.url);
107
108
  let seed = null;
108
109
  try { seed = JSON.parse(fs.readFileSync(seedPath, 'utf8')); } catch { seed = null; }
@@ -122,7 +123,7 @@ export function loadCalibrationHistory(scanRoot) {
122
123
  if (customer) merge(customer);
123
124
  // Merge triage-derived TP/FP counts (auto-feedback loop)
124
125
  try {
125
- const triage = _readJsonMaybe(path.join(scanRoot || process.cwd(), '.agentic-security', 'triage.json'));
126
+ const triage = _readJsonMaybe(statePath(scanRoot, 'triage.json'));
126
127
  if (triage && triage.findings) {
127
128
  const triageFams = {};
128
129
  for (const f of Object.values(triage.findings)) {
@@ -12,13 +12,19 @@ import * as fs from 'node:fs';
12
12
  import * as fsp from 'node:fs/promises';
13
13
  import * as path from 'node:path';
14
14
  import * as crypto from 'node:crypto';
15
+ import { isSafeStateDir, statePath } from './state-dir.js';
15
16
 
16
17
  function historyDir(scanRoot) {
17
- return path.join(scanRoot, '.agentic-security', 'fix-history');
18
+ return statePath(scanRoot, 'fix-history');
18
19
  }
19
20
  function logPath(scanRoot) { return path.join(historyDir(scanRoot), 'log.json'); }
20
21
 
21
- function ensure(scanRoot) { fs.mkdirSync(historyDir(scanRoot), { recursive: true }); }
22
+ function ensure(scanRoot) {
23
+ const dir = historyDir(scanRoot);
24
+ if (!isSafeStateDir(path.dirname(dir))) return false;
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ return true;
27
+ }
22
28
 
23
29
  export function readLog(scanRoot) {
24
30
  const fp = logPath(scanRoot);
@@ -6,6 +6,7 @@
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
8
  import * as yaml from 'js-yaml';
9
+ import { statePath, safeWriteState, resolveProjectRoot } from './state-dir.js';
9
10
 
10
11
  export const PROFILES = ['vibecoder', 'pro'];
11
12
 
@@ -35,7 +36,7 @@ export const DEFAULTS = {
35
36
  };
36
37
 
37
38
  function _profilePath(scanRoot) {
38
- return path.join(scanRoot || process.cwd(), '.agentic-security', 'profile.yml');
39
+ return statePath(scanRoot, 'profile.yml');
39
40
  }
40
41
 
41
42
  export function loadProfile(scanRoot) {
@@ -52,10 +53,8 @@ export function loadProfile(scanRoot) {
52
53
 
53
54
  export function saveProfile(scanRoot, updates) {
54
55
  const fp = _profilePath(scanRoot);
55
- fs.mkdirSync(path.dirname(fp), { recursive: true });
56
56
  const current = loadProfile(scanRoot);
57
57
  const next = { ...current, ...updates };
58
- // Strip values equal to defaults so the file stays minimal.
59
58
  const defaults = DEFAULTS[next.profile];
60
59
  const out = {};
61
60
  for (const k of Object.keys(next)) {
@@ -63,7 +62,7 @@ export function saveProfile(scanRoot, updates) {
63
62
  out[k] = next[k];
64
63
  }
65
64
  if (!('profile' in out)) out.profile = next.profile;
66
- fs.writeFileSync(fp, yaml.dump(out));
65
+ safeWriteState(fp, yaml.dump(out));
67
66
  return next;
68
67
  }
69
68
 
@@ -71,7 +70,7 @@ export function saveProfile(scanRoot, updates) {
71
70
  // Returns 'pro' if the repo has signals indicating professional security work,
72
71
  // otherwise 'vibecoder'. Run only on first scan.
73
72
  export function detectProfile(scanRoot) {
74
- const root = scanRoot || process.cwd();
73
+ const root = resolveProjectRoot(scanRoot);
75
74
  const signals = ['SECURITY.md', '.github/workflows/security.yml', '.semgrep.yml',
76
75
  '.snyk', 'codeql-config.yml', 'compliance/', 'docs/threat-model.md'];
77
76
  for (const s of signals) {
@@ -11,11 +11,10 @@ import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
  import * as yaml from 'js-yaml';
13
13
  import { verifyLastScan } from './integrity.js';
14
-
15
- const OVERRIDES_PATH = '.agentic-security/rules.yml';
14
+ import { statePath } from './state-dir.js';
16
15
 
17
16
  function _path(scanRoot) {
18
- return path.join(scanRoot || process.cwd(), OVERRIDES_PATH);
17
+ return statePath(scanRoot, 'rules.yml');
19
18
  }
20
19
 
21
20
  export function loadOverrides(scanRoot) {
@@ -30,8 +30,7 @@
30
30
  import * as fs from 'node:fs';
31
31
  import * as path from 'node:path';
32
32
  import * as crypto from 'node:crypto';
33
-
34
- const TRUSTED_KEYS_FILE = '.agentic-security/trusted-keys.json';
33
+ import { statePath } from './state-dir.js';
35
34
 
36
35
  // Built-in trust root. These are the keys the maintainers of agentic-security
37
36
  // use to sign official rule packs. Production deployment requires the
@@ -49,7 +48,7 @@ export const BUNDLED_OFFICIAL_KEYS = [
49
48
  ];
50
49
 
51
50
  function _trustedKeysPath(scanRoot) {
52
- return path.join(scanRoot || process.cwd(), TRUSTED_KEYS_FILE);
51
+ return statePath(scanRoot, 'trusted-keys.json');
53
52
  }
54
53
 
55
54
  // Load the EFFECTIVE trusted-key set. Composition:
@@ -13,14 +13,12 @@
13
13
 
14
14
  import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
-
17
- const TRIAGE_PATH = path.join('.agentic-security', 'triage-feedback.json');
18
- const PROPOSED_DIR = path.join('.agentic-security', 'rules-proposed');
16
+ import { statePath, isSafeStateDir } from './state-dir.js';
19
17
 
20
18
  const DEFAULT_FP_THRESHOLD = 5;
21
19
 
22
20
  function _readTriage(scanRoot) {
23
- const fp = path.join(scanRoot || process.cwd(), TRIAGE_PATH);
21
+ const fp = statePath(scanRoot, 'triage-feedback.json');
24
22
  if (!fs.existsSync(fp)) return null;
25
23
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
26
24
  }
@@ -87,7 +85,8 @@ export function synthesizeRules(scanRoot, opts = {}) {
87
85
  groups.get(k).push(e);
88
86
  }
89
87
  const proposals = [];
90
- const dir = path.join(scanRoot || process.cwd(), PROPOSED_DIR);
88
+ const dir = statePath(scanRoot, 'rules-proposed');
89
+ if (!opts.dryRun && !isSafeStateDir(path.dirname(dir))) return [];
91
90
  for (const [, group] of groups) {
92
91
  if (group.length < threshold) continue;
93
92
  const summary = _summarizeGroup(group);
@@ -105,4 +104,4 @@ export function synthesizeRules(scanRoot, opts = {}) {
105
104
  return proposals;
106
105
  }
107
106
 
108
- export const _internals = { DEFAULT_FP_THRESHOLD, TRIAGE_PATH, PROPOSED_DIR };
107
+ export const _internals = { DEFAULT_FP_THRESHOLD };