@clear-capabilities/agentic-security-scanner 0.79.0 → 0.84.1

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 (122) hide show
  1. package/dist/178.index.js +1 -1
  2. package/dist/333.index.js +283 -0
  3. package/dist/384.index.js +1 -1
  4. package/dist/637.index.js +1 -1
  5. package/dist/838.index.js +1 -1
  6. package/dist/839.index.js +170 -0
  7. package/dist/985.index.js +140 -1
  8. package/dist/agentic-security.mjs +10 -10
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +7 -5
  11. package/src/.agentic-security/findings.json +117732 -0
  12. package/src/.agentic-security/last-scan.json +117732 -0
  13. package/src/.agentic-security/last-scan.json.sig +1 -0
  14. package/src/.agentic-security/scan-history.json +12946 -0
  15. package/src/.agentic-security/streak.json +21 -0
  16. package/src/dataflow/.agentic-security/findings.json +6086 -0
  17. package/src/dataflow/.agentic-security/last-scan.json +6086 -0
  18. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
  19. package/src/dataflow/.agentic-security/scan-history.json +250 -0
  20. package/src/dataflow/.agentic-security/streak.json +21 -0
  21. package/src/dataflow/cross-service-taint.js +201 -0
  22. package/src/dataflow/formal-verify.js +204 -0
  23. package/src/dataflow/ifds-precise.js +222 -0
  24. package/src/dataflow/k2-summary-cache.js +153 -0
  25. package/src/dataflow/lib-taint-summaries.js +198 -0
  26. package/src/dataflow/privacy-taint.js +205 -0
  27. package/src/dataflow/smt-feasibility.js +189 -0
  28. package/src/engine.js +825 -127
  29. package/src/ir/.agentic-security/findings.json +4011 -0
  30. package/src/ir/.agentic-security/last-scan.json +4011 -0
  31. package/src/ir/.agentic-security/last-scan.json.sig +1 -0
  32. package/src/ir/.agentic-security/scan-history.json +193 -0
  33. package/src/ir/.agentic-security/streak.json +20 -0
  34. package/src/ir/cpp-preprocessor.js +142 -0
  35. package/src/ir/csharp-ir.js +604 -0
  36. package/src/ir/universal-ir.js +403 -0
  37. package/src/mcp/.agentic-security/findings.json +8632 -0
  38. package/src/mcp/.agentic-security/last-scan.json +8632 -0
  39. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  40. package/src/mcp/.agentic-security/scan-history.json +331 -0
  41. package/src/mcp/.agentic-security/streak.json +20 -0
  42. package/src/mcp/tools.js +140 -1
  43. package/src/posture/.agentic-security/findings.json +77181 -0
  44. package/src/posture/.agentic-security/last-scan.json +77181 -0
  45. package/src/posture/.agentic-security/last-scan.json.sig +1 -0
  46. package/src/posture/.agentic-security/scan-history.json +8904 -0
  47. package/src/posture/.agentic-security/streak.json +21 -0
  48. package/src/posture/api-contract.js +193 -0
  49. package/src/posture/attack-taxonomy.js +227 -0
  50. package/src/posture/auditor-walkthrough.js +252 -0
  51. package/src/posture/claude-authorship.js +197 -0
  52. package/src/posture/compliance-frameworks/.agentic-security/findings.json +80 -0
  53. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +80 -0
  54. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +1 -0
  55. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +90 -0
  56. package/src/posture/compliance-frameworks/.agentic-security/streak.json +22 -0
  57. package/src/posture/compliance-frameworks/ccpa.json +32 -0
  58. package/src/posture/compliance-frameworks/eu-ai-act.json +51 -0
  59. package/src/posture/compliance-frameworks/gdpr.json +45 -0
  60. package/src/posture/compliance-frameworks/hipaa-security-rule.json +56 -0
  61. package/src/posture/compliance-frameworks/nist-ai-600-1.json +51 -0
  62. package/src/posture/compliance-frameworks/nist-csf-2.json +73 -0
  63. package/src/posture/compliance-frameworks/owasp-asvs-5.json +79 -0
  64. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +69 -0
  65. package/src/posture/compliance-policy.js +218 -0
  66. package/src/posture/composite-risk.js +122 -0
  67. package/src/posture/cross-repo-memory.js +180 -0
  68. package/src/posture/csharp-analysis.js +330 -0
  69. package/src/posture/dep-add-guard.js +197 -0
  70. package/src/posture/exploit-bundle.js +210 -0
  71. package/src/posture/federated-learning.js +172 -0
  72. package/src/posture/findings-memory.js +152 -0
  73. package/src/posture/fix-style-mirror.js +118 -0
  74. package/src/posture/git-history.js +141 -0
  75. package/src/posture/intent-context.js +175 -0
  76. package/src/posture/license-attributions.js +94 -0
  77. package/src/posture/license-graph.js +238 -0
  78. package/src/posture/model-rescan.js +76 -0
  79. package/src/posture/pattern-propagation.js +39 -0
  80. package/src/posture/pqc-migration-plan.js +158 -0
  81. package/src/posture/pr-augment.js +234 -0
  82. package/src/posture/reachability-filter.js +33 -2
  83. package/src/posture/realtime-cve-monitor.js +214 -0
  84. package/src/posture/risk-dollars.js +158 -0
  85. package/src/posture/runtime-correlation.js +174 -0
  86. package/src/posture/sbom-diff.js +171 -0
  87. package/src/posture/sca-policy.js +235 -0
  88. package/src/posture/sca-upgrade.js +259 -0
  89. package/src/posture/threat-model-auto.js +268 -0
  90. package/src/posture/threat-model-grounding.js +169 -0
  91. package/src/posture/time-to-fix.js +129 -0
  92. package/src/posture/triage-learning.js +170 -0
  93. package/src/posture/triage-memory.js +151 -0
  94. package/src/posture/triage.js +40 -1
  95. package/src/posture/watch-mode.js +171 -0
  96. package/src/posture/workflow-installer.js +231 -0
  97. package/src/sast/.agentic-security/findings.json +6154 -0
  98. package/src/sast/.agentic-security/last-scan.json +6154 -0
  99. package/src/sast/.agentic-security/last-scan.json.sig +1 -0
  100. package/src/sast/.agentic-security/scan-history.json +941 -0
  101. package/src/sast/.agentic-security/streak.json +22 -0
  102. package/src/sast/_secret-entropy.js +145 -0
  103. package/src/sast/cloud-iam.js +312 -0
  104. package/src/sast/cpp.js +138 -4
  105. package/src/sast/crypto-protocol.js +388 -0
  106. package/src/sast/csharp-tokenizer.js +392 -0
  107. package/src/sast/csharp.js +924 -138
  108. package/src/sast/dapp-frontend.js +200 -0
  109. package/src/sast/k8s-admission.js +271 -0
  110. package/src/sast/llm-app.js +272 -0
  111. package/src/sast/ml-supply-chain.js +259 -0
  112. package/src/sast/mobile.js +224 -0
  113. package/src/sast/post-quantum-crypto.js +348 -0
  114. package/src/sast/web3-advanced.js +375 -0
  115. package/src/sca/.agentic-security/findings.json +7460 -0
  116. package/src/sca/.agentic-security/last-scan.json +7460 -0
  117. package/src/sca/.agentic-security/last-scan.json.sig +1 -0
  118. package/src/sca/.agentic-security/scan-history.json +113 -0
  119. package/src/sca/.agentic-security/streak.json +21 -0
  120. package/src/sca/CLAUDE.md +161 -0
  121. package/src/sca/binary-metadata.js +37 -15
  122. package/src/sca/sigstore-verify.js +215 -0
package/src/engine.js CHANGED
@@ -68,6 +68,7 @@ import { scanLlmRedteam } from './posture/llm-redteam.js';
68
68
  import { scanContainer } from './sca/container.js';
69
69
  import { detectDepConfusion } from './sca/dep-confusion.js';
70
70
  import { loadLicensePolicy, evaluateLicensePolicy } from './posture/license-policy.js';
71
+ import { loadScaPolicy, applyScaPolicy } from './posture/sca-policy.js';
71
72
  import { scanDeployPlatform } from './posture/deploy-platform.js';
72
73
  import { runStackPlaybook } from './posture/stack-playbook.js';
73
74
  // Phase 1 (Sentinel-parity PRD) — new detection modules.
@@ -92,6 +93,7 @@ import { scanDeserializationGadgets, _detectGadgets } from './sast/deserializati
92
93
  import { scanKotlin } from './sast/kotlin.js';
93
94
  import { scanRuby } from './sast/ruby.js';
94
95
  import { scanPhp } from './sast/php.js';
96
+ import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
95
97
  // Phase 1 — precision-engineering posture modules.
96
98
  import { annotateConfidence } from './posture/confidence.js';
97
99
  import { backfillFindingDefaults } from './posture/finding-defaults.js';
@@ -126,6 +128,44 @@ import { annotateCrownJewelScores } from './posture/crown-jewels.js';
126
128
  import { annotateFeatureFlagGating } from './posture/feature-flags.js';
127
129
  import { annotatePersonaScores } from './posture/persona-prioritization.js';
128
130
  import { annotateMitigationComposite } from './posture/mitigation-composite.js';
131
+ import { annotateCompositeRisk } from './posture/composite-risk.js';
132
+
133
+ // ── Integration block: world-class scaffolded modules ──────────────────────
134
+ // Each module is opt-in via its own env var so partial adoption is safe.
135
+ // AGENTIC_SECURITY_NO_INTEGRATION=1 disables the entire block (for CI bench
136
+ // runs that need bit-identical baselines).
137
+ import { scanLlmApp } from './sast/llm-app.js';
138
+ import { scanMobile } from './sast/mobile.js';
139
+ import { scanPqc } from './sast/post-quantum-crypto.js';
140
+ import { scanWeb3Advanced } from './sast/web3-advanced.js';
141
+ import { scanDappFrontend } from './sast/dapp-frontend.js';
142
+ import { scanCloudIam } from './sast/cloud-iam.js';
143
+ import { scanK8sAdmission } from './sast/k8s-admission.js';
144
+ import { scanCryptoProtocol } from './sast/crypto-protocol.js';
145
+ import { scanMlSupplyChain } from './sast/ml-supply-chain.js';
146
+ import { runCrossServiceTaint } from './dataflow/cross-service-taint.js';
147
+ import { annotateRuntimeCorrelation } from './posture/runtime-correlation.js';
148
+ import { applyLearnedCalibration } from './posture/triage-learning.js';
149
+ import { annotateFormalVerification } from './dataflow/formal-verify.js';
150
+ import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
151
+ import { annotatePrivacyTaint, emitDpiaArtifact } from './dataflow/privacy-taint.js';
152
+ import { buildThreatModel as buildAutoThreatModel, persistThreatModel as persistAutoThreatModel } from './posture/threat-model-auto.js';
153
+ import { runApiContractScan } from './posture/api-contract.js';
154
+ import { annotateProvenance } from './sca/sigstore-verify.js';
155
+ import { runSbomDiff } from './posture/sbom-diff.js';
156
+ import { loadPolicy as loadCompliancePolicy, verifyPolicy as verifyCompliancePolicy, emitEvidenceJsonLd as emitComplianceJsonLd, emitEvidenceMarkdown as emitComplianceMarkdown } from './posture/compliance-policy.js';
157
+ import { generateBundles as generateExploitBundles } from './posture/exploit-bundle.js';
158
+ import { buildMigrationPlan as buildPqcPlan, persistMigrationPlan as persistPqcPlan } from './posture/pqc-migration-plan.js';
159
+ import { analyzeLicenseGraph, loadLicenseGraphPolicy } from './posture/license-graph.js';
160
+ import { generateAttributions, persistAttributions } from './posture/license-attributions.js';
161
+ import { annotateAttackTaxonomy, summarizeTaxonomy } from './posture/attack-taxonomy.js';
162
+ import { suppressByPastDecisions } from './posture/triage-memory.js';
163
+ import { suppressByIntent } from './posture/intent-context.js';
164
+ import { annotateGitHistory } from './posture/git-history.js';
165
+ import { applyThreatModel } from './posture/threat-model-grounding.js';
166
+ import { annotateCrossRepoSignals } from './posture/pattern-propagation.js';
167
+ import { annotateRiskDollars } from './posture/risk-dollars.js';
168
+ import { annotateTimeToFix } from './posture/time-to-fix.js';
129
169
  import { annotateTypeNarrowing } from './posture/type-narrowing.js';
130
170
  import { annotateWhyFired } from './posture/why-fired.js';
131
171
  import { scanSpecificationDrift } from './posture/specification-mining.js';
@@ -2049,6 +2089,13 @@ function _isFalsePositiveCredential(fp, snippet, fullMatch){
2049
2089
  const val = valM ? valM[1] : '';
2050
2090
  if (val.length < 8) return {skip:true, reason:'value-too-short'};
2051
2091
  if (_CRED_PLACEHOLDER_VAL_RE.test(val)) return {skip:true, reason:'placeholder-value'};
2092
+ // Shannon-entropy + dictionary-word filter (Recommendation #1 from the
2093
+ // SCA/SAST improvement plan). The Juliet Java hardcoded-secret detector
2094
+ // was producing 468 FPs / 1 TP because test fixtures use short
2095
+ // dictionary words ("hello", "password", "todo") as fake credentials.
2096
+ // Real secrets score ≥3.5 bits/char and aren't dictionary words.
2097
+ const entropyVerdict = _entropyClassifySecret(val);
2098
+ if (entropyVerdict.skip) return { skip: true, reason: `entropy-filter:${entropyVerdict.reason}` };
2052
2099
  // Non-ASCII content with i18n-shaped variable name → translation string
2053
2100
  if (_CRED_I18N_VAL_RE.test(val) && /(?:label|message|text|title|description|placeholder)/i.test(varName)) {
2054
2101
  return {skip:true, reason:'i18n-text'};
@@ -2713,7 +2760,16 @@ const JAVA_FAMILY_RULES = [
2713
2760
  // Generic verbs (update / insert / delete / count / query) removed — they
2714
2761
  // misfire on hash.update, list.insert/delete/count, etc. JdbcTemplate's
2715
2762
  // verb-based methods still match via batchUpdate / queryForObject etc.
2716
- sinkRe: /\.\s*(?:executeQuery|executeUpdate|execute|executeBatch|prepareStatement|prepareCall|createQuery|createNativeQuery|createSQLQuery|createCriteriaQuery|createSqlQuery|addBatch|queryForObject|queryForList|queryForMap|queryForLong|queryForInt|queryForRowSet|batchUpdate|find_by_sql)\s*\(/,
2763
+ //
2764
+ // Recommendation #4 of the SCA/SAST improvement plan: expanded sink list.
2765
+ // Added: NamedParameterJdbcTemplate.{query,queryForObject,…} (Spring),
2766
+ // EntityManager.createQuery+createNativeQuery (JPA),
2767
+ // Session.createNativeQuery + createSQLQuery (Hibernate native),
2768
+ // Criteria.add(Restrictions.sqlRestriction(…)) (Hibernate criteria),
2769
+ // SqlSession.{selectList,selectOne,selectMap,update,insert,delete} (MyBatis),
2770
+ // JdbcTemplate.{queryForStream,queryForRowSet}, R2DBC DatabaseClient.sql,
2771
+ // JdbcOperations / JdbcAggregateOperations.* (Spring Data JDBC).
2772
+ sinkRe: /\.\s*(?:executeQuery|executeUpdate|execute|executeBatch|prepareStatement|prepareCall|createQuery|createNativeQuery|createSQLQuery|createCriteriaQuery|createSqlQuery|createStatement|addBatch|queryForObject|queryForList|queryForMap|queryForLong|queryForInt|queryForRowSet|queryForStream|batchUpdate|find_by_sql|sqlRestriction|selectList|selectOne|selectMap|selectCursor|sql)\s*\(/,
2717
2773
  sanitizerRe: null,
2718
2774
  useTaint: true,
2719
2775
  },
@@ -5632,41 +5688,125 @@ function markUsedVulnFunctions(supplyChain,fc){
5632
5688
  }
5633
5689
 
5634
5690
  // Annotate each supplyChain finding with `functionReachable` ∈ {'reachable','unreachable','unknown'}.
5635
- // The CVE only matters if the developer's code actually calls the vulnerable function
5636
- // AND that call site sits in code reachable from a route handler.
5637
- function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
5638
- for(const sc of (supplyChain||[])){
5639
- if(sc.type!=='vulnerable_dep')continue;
5640
- const sites=sc.vulnerableFunctionCallSites||[];
5641
- if(!sites.length){sc.functionReachable='unknown';continue;}
5642
- let reachable=false;
5643
- for(const site of sites){
5644
- // Classifier 1: site is inline inside a route handler (within 25 lines of the route def)
5645
- const fileRoutes=(routes||[]).filter(r=>r.file===site.file);
5646
- for(const route of fileRoutes){
5647
- if(site.line>=route.line&&site.line<=route.line+25){
5648
- // Make sure no function declaration intervenes
5649
- const fileLines=(fc[site.file]||'').split('\n');
5650
- const between=fileLines.slice(route.line,site.line-1).join('\n');
5651
- if(!/function\s+\w+\s*\(/.test(between)){reachable=true;break;}
5691
+ // The CVE only matters if the developer's code actually calls the vulnerable
5692
+ // function. The question is HOW reachable: is the call inline in a route
5693
+ // handler (definitely user-input-reachable), called by some caller eventually
5694
+ // reached from a route (transitively reachable), or called by code that no
5695
+ // route ever touches (function-reachable but not route-reachable)?
5696
+ //
5697
+ // Phase 2 / Item 4 of the SCA improvement plan: distinguish these tiers.
5698
+ // Sets two fields:
5699
+ // sc.functionReachable: 'reachable' | 'unreachable' | 'unknown'
5700
+ // sc.routeReachable: true | false (only meaningful when functionReachable === 'reachable')
5701
+ //
5702
+ // Used by the tier-assignment block downstream to label
5703
+ // `reachabilityTier: 'route-reachable-via-function'` (highest urgency) vs
5704
+ // `'function-reachable'` (called but not chained to a route).
5705
+
5706
+ // Set of every (file, fnName) that *is* a route handler. The enclosing
5707
+ // function at a route's declaration line, mapped from the (file, line) pairs
5708
+ // the route scanner emitted.
5709
+ function _buildRouteHandlerSet(routes, fc){
5710
+ const handlers = new Set(); // 'file::fnName'
5711
+ for (const r of (routes || [])) {
5712
+ const content = fc[r.file];
5713
+ if (typeof content !== 'string') continue;
5714
+ const fn = _enclosingFn(content, r.line);
5715
+ if (fn) handlers.add(r.file + '::' + fn);
5716
+ // Capture an anonymous handler too — many JS routes are
5717
+ // `app.get('/x', (req, res) => { … })` where the route line itself IS
5718
+ // the function body. The body's identifier is unstable but we can mark
5719
+ // the file:line for the inline check.
5720
+ }
5721
+ return handlers;
5722
+ }
5723
+
5724
+ // Reverse-BFS through the call graph: starting from `enclosingFn` in `file`,
5725
+ // find every function (anywhere in the program) that transitively calls it,
5726
+ // up to `maxDepth`. Returns the set of caller fnNames.
5727
+ function _reverseCallGraphReachable(callGraph, startFn, maxDepth){
5728
+ if (!callGraph || typeof callGraph !== 'object') return new Set();
5729
+ const visited = new Set([startFn]);
5730
+ let frontier = new Set([startFn]);
5731
+ for (let depth = 0; depth < maxDepth && frontier.size; depth++) {
5732
+ const next = new Set();
5733
+ for (const [file, fileFns] of Object.entries(callGraph)) {
5734
+ if (!fileFns || typeof fileFns !== 'object') continue;
5735
+ for (const [fn, info] of Object.entries(fileFns)) {
5736
+ if (visited.has(file + '::' + fn) || visited.has(fn)) continue;
5737
+ const calls = info && info.calls;
5738
+ if (!calls) continue;
5739
+ for (const target of frontier) {
5740
+ const hit = calls.has?.(target) || (Array.isArray(calls) && calls.includes(target));
5741
+ if (hit) {
5742
+ next.add(fn);
5743
+ // Track both qualified and unqualified — the route-handler set
5744
+ // uses 'file::fn' but the call graph carries bare fn names.
5745
+ visited.add(fn);
5746
+ visited.add(file + '::' + fn);
5747
+ break;
5748
+ }
5652
5749
  }
5653
5750
  }
5654
- if(reachable)break;
5655
- // Classifier 2: enclosing function appears in another named function's calls (cross-fn reachability)
5656
- const enclosing=_enclosingFn(fc[site.file]||'',site.line);
5657
- if(enclosing){
5658
- // callGraph is {filePath: {fnName: {calls: Set, ...}}}
5659
- outer: for(const[,fileFns] of Object.entries(callGraph||{})){
5660
- if(typeof fileFns!=='object'||!fileFns)continue;
5661
- for(const[fn,info] of Object.entries(fileFns)){
5662
- const calls=info&&info.calls;
5663
- if(fn!==enclosing&&calls&&(calls.has?.(enclosing)||(Array.isArray(calls)&&calls.includes(enclosing)))){reachable=true;break outer;}
5751
+ }
5752
+ frontier = next;
5753
+ }
5754
+ return visited;
5755
+ }
5756
+
5757
+ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
5758
+ const routeHandlers = _buildRouteHandlerSet(routes, fc);
5759
+ // Pre-extract just the unqualified names too — the call-graph traversal
5760
+ // matches on bare fnName when the qualified form isn't available.
5761
+ const routeHandlerNames = new Set();
5762
+ for (const k of routeHandlers) {
5763
+ const fn = k.split('::')[1];
5764
+ if (fn) routeHandlerNames.add(fn);
5765
+ }
5766
+
5767
+ for (const sc of (supplyChain || [])) {
5768
+ if (sc.type !== 'vulnerable_dep') continue;
5769
+ const sites = sc.vulnerableFunctionCallSites || [];
5770
+ if (!sites.length) { sc.functionReachable = 'unknown'; sc.routeReachable = false; continue; }
5771
+ let functionReachable = false;
5772
+ let routeReachable = false;
5773
+ for (const site of sites) {
5774
+ // Classifier 1: site is inline inside a route handler (within 25 lines
5775
+ // of the route def, no intervening function declaration). This is the
5776
+ // strongest signal — the vulnerable function is called directly from
5777
+ // user-input handling code.
5778
+ const fileRoutes = (routes || []).filter(r => r.file === site.file);
5779
+ for (const route of fileRoutes) {
5780
+ if (site.line >= route.line && site.line <= route.line + 25) {
5781
+ const fileLines = (fc[site.file] || '').split('\n');
5782
+ const between = fileLines.slice(route.line, site.line - 1).join('\n');
5783
+ if (!/function\s+\w+\s*\(/.test(between)) {
5784
+ functionReachable = true;
5785
+ routeReachable = true;
5786
+ break;
5664
5787
  }
5665
5788
  }
5666
5789
  }
5667
- if(reachable)break;
5790
+ if (routeReachable) break;
5791
+ // Classifier 2: walk reverse call graph from the enclosing function.
5792
+ // If any caller-chain hits a known route-handler function, the site
5793
+ // is route-reachable-via-function. If no caller at all, we keep
5794
+ // functionReachable=false for this site.
5795
+ const enclosing = _enclosingFn(fc[site.file] || '', site.line);
5796
+ if (!enclosing) continue;
5797
+ const callers = _reverseCallGraphReachable(callGraph, enclosing, 4);
5798
+ if (callers.size > 1) functionReachable = true; // at least one caller exists
5799
+ for (const callerFn of callers) {
5800
+ if (routeHandlerNames.has(callerFn) || routeHandlers.has(site.file + '::' + callerFn)) {
5801
+ routeReachable = true;
5802
+ functionReachable = true;
5803
+ break;
5804
+ }
5805
+ }
5806
+ if (routeReachable) break;
5668
5807
  }
5669
- sc.functionReachable=reachable?'reachable':'unreachable';
5808
+ sc.functionReachable = functionReachable ? 'reachable' : 'unreachable';
5809
+ sc.routeReachable = routeReachable;
5670
5810
  }
5671
5811
  }
5672
5812
  function _enclosingFn(content,line){
@@ -5964,39 +6104,72 @@ function _osvCacheGet(key){try{const r=sessionStorage.getItem('osv_'+key);return
5964
6104
  function _osvCacheSet(key,val){try{sessionStorage.setItem('osv_'+key,JSON.stringify(val));}catch(_){}}
5965
6105
 
5966
6106
  // Feat-9: EPSS (community abuse-probability index) overlay. Fetches probability
5967
- // of abuse in the next 30 days per CVE; caches on disk. When offline or
6107
+ // of abuse in the next 30 days; caches per-CVE on disk. When offline or
5968
6108
  // the API errors, falls back to null fields — never blocks the scan.
5969
- async function _fetchEPSS(cveId){
5970
- if (!cveId || !/^CVE-\d{4}-\d+$/i.test(cveId)) return null;
5971
- if (process.env.AGENTIC_SECURITY_OFFLINE === '1') return null;
5972
- const cached = _osvCacheGet('epss:'+cveId);
5973
- if (cached !== null) return cached;
5974
- try {
5975
- const res = await fetch(`https://api.first.org/data/v1/epss?cve=${encodeURIComponent(cveId)}`, {
5976
- headers: { 'User-Agent': 'agentic-security/0.1' },
5977
- });
5978
- if (!res.ok) { _osvCacheSet('epss:'+cveId, false); return null; }
5979
- const j = await res.json();
5980
- const row = j.data?.[0];
5981
- const out = row ? { score: parseFloat(row.epss), percentile: parseFloat(row.percentile) } : null;
5982
- _osvCacheSet('epss:'+cveId, out || false);
5983
- return out;
5984
- } catch { return null; }
6109
+ //
6110
+ // Batched: api.first.org accepts up to ~100 CVEs per request via ?cve=A,B,C…
6111
+ // One HTTP round trip per 100 CVEs instead of one per CVE. Cache lookups
6112
+ // remain per-CVE so a partial cache-hit still benefits.
6113
+ const _EPSS_BATCH = 100;
6114
+ async function _fetchEPSSBatch(cveIds){
6115
+ if (!cveIds || !cveIds.length) return new Map();
6116
+ if (process.env.AGENTIC_SECURITY_OFFLINE === '1') return new Map();
6117
+ const out = new Map();
6118
+ // Filter to well-formed CVE ids only.
6119
+ const fresh = cveIds.filter(c => /^CVE-\d{4}-\d+$/i.test(c));
6120
+ for (let i = 0; i < fresh.length; i += _EPSS_BATCH){
6121
+ const batch = fresh.slice(i, i + _EPSS_BATCH);
6122
+ const url = `https://api.first.org/data/v1/epss?cve=${encodeURIComponent(batch.join(','))}`;
6123
+ try {
6124
+ const res = await fetch(url, { headers: { 'User-Agent': 'agentic-security/0.1' } });
6125
+ if (!res.ok) {
6126
+ // Mark every CVE in the batch as "tried and failed" so we don't refetch this scan.
6127
+ for (const c of batch) _osvCacheSet('epss:'+c, false);
6128
+ continue;
6129
+ }
6130
+ const j = await res.json();
6131
+ const seen = new Set();
6132
+ for (const row of (j.data || [])) {
6133
+ const cve = (row.cve || '').toUpperCase();
6134
+ if (!cve) continue;
6135
+ const score = parseFloat(row.epss);
6136
+ const percentile = parseFloat(row.percentile);
6137
+ if (Number.isFinite(score) && Number.isFinite(percentile)) {
6138
+ const v = { score, percentile };
6139
+ out.set(cve, v);
6140
+ _osvCacheSet('epss:'+cve, v);
6141
+ seen.add(cve);
6142
+ }
6143
+ }
6144
+ // CVEs in the batch that EPSS does not know — cache the negative so we
6145
+ // don't retry within this scan run.
6146
+ for (const c of batch) if (!seen.has(c.toUpperCase())) _osvCacheSet('epss:'+c, false);
6147
+ } catch { /* network error → caller continues without enrichment */ }
6148
+ }
6149
+ return out;
5985
6150
  }
5986
6151
 
5987
6152
  async function _enrichWithEPSS(supplyChainResults){
5988
6153
  const out = supplyChainResults;
5989
- const cves = new Set();
5990
- for (const r of out) for (const a of (r.cveAliases || [])) if (/^CVE-/.test(a)) cves.add(a);
5991
- // Fetch in parallel, deduped per CVE
6154
+ // Collect every unique CVE referenced by any SCA finding.
6155
+ const allCves = new Set();
6156
+ for (const r of out) for (const a of (r.cveAliases || [])) if (/^CVE-/.test(a)) allCves.add(a.toUpperCase());
6157
+ if (!allCves.size) return out;
6158
+ // Cache lookup pass: keep CVEs we already have, defer the rest to one batched fetch.
5992
6159
  const epssByCve = new Map();
5993
- await Promise.all([...cves].map(async (cve) => {
5994
- const epss = await _fetchEPSS(cve);
5995
- if (epss) epssByCve.set(cve, epss);
5996
- }));
6160
+ const uncached = [];
6161
+ for (const c of allCves) {
6162
+ const hit = _osvCacheGet('epss:'+c);
6163
+ if (hit === null) uncached.push(c);
6164
+ else if (hit) epssByCve.set(c, hit); // hit === false means "tried, no data" — leave unset
6165
+ }
6166
+ if (uncached.length) {
6167
+ const fetched = await _fetchEPSSBatch(uncached);
6168
+ for (const [cve, v] of fetched) epssByCve.set(cve, v);
6169
+ }
5997
6170
  for (const r of out) {
5998
6171
  const cve = (r.cveAliases || []).find(a => /^CVE-/.test(a));
5999
- const epss = cve ? epssByCve.get(cve) : null;
6172
+ const epss = cve ? epssByCve.get(cve.toUpperCase()) : null;
6000
6173
  if (epss) {
6001
6174
  r.epssScore = epss.score;
6002
6175
  r.epssPercentile = epss.percentile;
@@ -6248,22 +6421,131 @@ function _parseCargoLock(text,filePath){
6248
6421
  return out;
6249
6422
  }
6250
6423
 
6251
- function _parsePomXml(text,filePath){
6252
- const out=[];
6253
- for(const block of text.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)){
6254
- const inner=block[1];
6255
- const gM=inner.match(/<groupId>([^<]+)<\/groupId>/);
6256
- const aM=inner.match(/<artifactId>([^<]+)<\/artifactId>/);
6257
- const vM=inner.match(/<version>([^<]+)<\/version>/);
6258
- const sM=inner.match(/<scope>([^<]+)<\/scope>/);
6259
- if(!gM||!aM)continue;
6260
- const group=gM[1].trim();const artifact=aM[1].trim();
6261
- const ver=vM?vM[1].trim().replace(/^\$\{[^}]+\}$/,'0.0.0'):'0.0.0';
6262
- const scope=sM&&(sM[1]==='test'||sM[1]==='provided')?'optional':'required';
6263
- out.push({name:`${group}:${artifact}`,version:ver,group,scope,
6264
- purl:_makePurl('maven',artifact,ver,group),ecosystem:'maven',filePath,
6265
- isUnpinned:ver==='0.0.0'||ver.startsWith('$')});
6266
- }return out;
6424
+ // Strip XML comments before tag-extraction so the regex doesn't accidentally
6425
+ // match a commented-out <dependency> block.
6426
+ function _stripPomXmlComments(text){
6427
+ return text.replace(/<!--[\s\S]*?-->/g, '');
6428
+ }
6429
+
6430
+ // Build a property table from <properties>...</properties> AND from common
6431
+ // child elements (<project.version>, etc.). Used to substitute ${propName}
6432
+ // version references. We do TWO passes so a property that references
6433
+ // another property (rare but legal — `${spring.boot.version}`) resolves.
6434
+ function _pomPropertyMap(text){
6435
+ const props = new Map();
6436
+ // <properties> block — the canonical place for user-defined properties.
6437
+ const propsBlock = text.match(/<properties>([\s\S]*?)<\/properties>/);
6438
+ if (propsBlock) {
6439
+ for (const m of propsBlock[1].matchAll(/<([\w.-]+)>([^<]+)<\/\1>/g)) {
6440
+ props.set(m[1].trim(), m[2].trim());
6441
+ }
6442
+ }
6443
+ // Resolve cross-property references (one pass is sufficient for typical
6444
+ // pom.xml shapes — chains deeper than 1 are vanishingly rare).
6445
+ for (const [k, v] of props) {
6446
+ if (v.includes('${')) {
6447
+ const resolved = v.replace(/\$\{([^}]+)\}/g, (_, name) => props.get(name) ?? '');
6448
+ props.set(k, resolved);
6449
+ }
6450
+ }
6451
+ return props;
6452
+ }
6453
+
6454
+ function _resolvePomVersion(raw, props){
6455
+ if (!raw) return '0.0.0';
6456
+ let v = raw.trim();
6457
+ // Strip Maven's range/qualifier wrappers that aren't useful for SCA matching.
6458
+ // `[1.0,2.0)` → take the lower bound.
6459
+ const rangeMatch = v.match(/^[\[\(]\s*([^,\]\)]+)/);
6460
+ if (rangeMatch) v = rangeMatch[1].trim();
6461
+ // Substitute ${propName} references — return '0.0.0' so OSV knows it's
6462
+ // unresolvable rather than silently dropping the dep.
6463
+ v = v.replace(/\$\{([^}]+)\}/g, (_, name) => props.get(name) || '');
6464
+ if (!v || v.startsWith('$')) return '0.0.0';
6465
+ return v;
6466
+ }
6467
+
6468
+ // Parse a single <dependency>…</dependency> block to a component shape.
6469
+ // Used by both the top-level <dependencies> walk and the
6470
+ // <dependencyManagement>/<dependencies>/<dependency> BOM-import walk.
6471
+ function _parsePomDependencyBlock(inner, filePath, props, source){
6472
+ const gM = inner.match(/<groupId>([^<]+)<\/groupId>/);
6473
+ const aM = inner.match(/<artifactId>([^<]+)<\/artifactId>/);
6474
+ const vM = inner.match(/<version>([^<]+)<\/version>/);
6475
+ const sM = inner.match(/<scope>([^<]+)<\/scope>/);
6476
+ const tM = inner.match(/<type>([^<]+)<\/type>/);
6477
+ if (!gM || !aM) return null;
6478
+ const group = _resolvePomVersion(gM[1], props) || gM[1].trim();
6479
+ const artifact = _resolvePomVersion(aM[1], props) || aM[1].trim();
6480
+ const ver = _resolvePomVersion(vM ? vM[1] : '', props);
6481
+ const scopeRaw = sM ? sM[1].trim() : '';
6482
+ const scope = (scopeRaw === 'test' || scopeRaw === 'provided') ? 'optional' : 'required';
6483
+ return {
6484
+ name: `${group}:${artifact}`, version: ver, group, scope,
6485
+ purl: _makePurl('maven', artifact, ver, group), ecosystem: 'maven', filePath,
6486
+ isUnpinned: ver === '0.0.0' || ver.startsWith('$'),
6487
+ isTransitive: false,
6488
+ pomSource: source, // 'direct' | 'managed'
6489
+ pomType: tM ? tM[1].trim() : null, // 'jar' (default) | 'pom' (BOM import) | etc.
6490
+ };
6491
+ }
6492
+
6493
+ function _parsePomXml(text, filePath){
6494
+ const cleanText = _stripPomXmlComments(text);
6495
+ const props = _pomPropertyMap(cleanText);
6496
+ const out = [];
6497
+ // Identify the <dependencyManagement> block once so we can label its
6498
+ // contents as `pomSource: 'managed'` (BOM-imported) without doubly-emitting.
6499
+ const mgmtBlock = cleanText.match(/<dependencyManagement>([\s\S]*?)<\/dependencyManagement>/);
6500
+ const mgmtStart = mgmtBlock ? mgmtBlock.index : -1;
6501
+ const mgmtEnd = mgmtBlock ? mgmtBlock.index + mgmtBlock[0].length : -1;
6502
+ for (const block of cleanText.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
6503
+ const inBomImport = mgmtStart >= 0 && block.index >= mgmtStart && block.index < mgmtEnd;
6504
+ const comp = _parsePomDependencyBlock(block[1], filePath, props, inBomImport ? 'managed' : 'direct');
6505
+ if (comp) out.push(comp);
6506
+ }
6507
+ return out;
6508
+ }
6509
+
6510
+ // Maven's `mvn dependency:tree -DoutputFile=target/dependency-tree.txt`
6511
+ // emits the FULL resolved transitive graph. When that file is present, we
6512
+ // ingest it for transitive coverage — pom.xml itself only declares direct
6513
+ // deps. Format (per dep, one line, with tree-drawing prefixes we strip):
6514
+ // groupId:artifactId:type:version:scope
6515
+ // +- groupId:artifactId:type:version:scope
6516
+ // | \- groupId:artifactId:type:version:scope
6517
+ // The first line is the project itself; we skip it.
6518
+ function _parseMavenDependencyTree(text, filePath){
6519
+ const out = [];
6520
+ let isFirstLine = true;
6521
+ for (const rawLine of text.split('\n')) {
6522
+ if (!rawLine.trim()) continue;
6523
+ // Strip the tree-drawing prefix: spaces, pipes, dashes, plus signs, backslashes.
6524
+ const stripped = rawLine.replace(/^[\s|+\\-]+/, '').trim();
6525
+ if (!stripped) continue;
6526
+ if (isFirstLine) { isFirstLine = false; continue; }
6527
+ // Format: groupId:artifactId:type:version[:scope]
6528
+ // type is typically `jar` but can be `pom`, `war`, `aar`. Some POMs include
6529
+ // a classifier: groupId:artifactId:type:classifier:version[:scope] (5–6 parts).
6530
+ const parts = stripped.split(':');
6531
+ if (parts.length < 4) continue;
6532
+ const group = parts[0];
6533
+ const artifact = parts[1];
6534
+ // type at [2]; classifier (optional) at [3] when length >= 6.
6535
+ const hasClassifier = parts.length >= 6;
6536
+ const ver = hasClassifier ? parts[4] : parts[3];
6537
+ const scopeRaw = hasClassifier ? parts[5] : parts[4];
6538
+ if (!group || !artifact || !ver) continue;
6539
+ const scope = (scopeRaw === 'test' || scopeRaw === 'provided') ? 'optional' : 'required';
6540
+ out.push({
6541
+ name: `${group}:${artifact}`, version: ver, group, scope,
6542
+ purl: _makePurl('maven', artifact, ver, group), ecosystem: 'maven', filePath,
6543
+ isUnpinned: false,
6544
+ isTransitive: rawLine.match(/^[\s|+\\-]/) ? true : false,
6545
+ pomSource: 'dependency-tree',
6546
+ });
6547
+ }
6548
+ return out;
6267
6549
  }
6268
6550
 
6269
6551
  function _parseBuildGradle(text,filePath){
@@ -6432,8 +6714,119 @@ function _parseVcpkgJson(text,filePath){
6432
6714
  }
6433
6715
  }catch(_){}return out;
6434
6716
  }
6717
+
6718
+ // go.sum: the full resolved transitive dependency graph for a Go module.
6719
+ // go.mod only declares direct deps; go.sum is what `go mod download` resolved.
6720
+ // Format (two lines per module — one for the module, one for go.mod):
6721
+ // github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
6722
+ // github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
6723
+ // We extract the module-line variant (no `/go.mod` suffix) so each module
6724
+ // gets exactly one component. Empty go.sum (no deps) gracefully returns [].
6725
+ function _parseGoSum(text, filePath){
6726
+ const out = [];
6727
+ const seen = new Set();
6728
+ for (const line of text.split('\n')) {
6729
+ const t = line.trim();
6730
+ if (!t || t.startsWith('//')) continue;
6731
+ // Skip the /go.mod hash line — it would double-count each module.
6732
+ if (t.includes('/go.mod ')) continue;
6733
+ const m = t.match(/^(\S+)\s+v([^\s]+)\s+h1:/);
6734
+ if (!m) continue;
6735
+ const name = m[1];
6736
+ // Strip +incompatible / -timestamp-sha suffixes that aren't useful for OSV matching.
6737
+ const ver = m[2].replace(/\+incompatible$/, '').replace(/^v?/, '');
6738
+ const dedupKey = `${name}@${ver}`;
6739
+ if (seen.has(dedupKey)) continue;
6740
+ seen.add(dedupKey);
6741
+ out.push({
6742
+ name, version: ver, group: '', scope: 'required',
6743
+ purl: _makePurl('golang', name, ver, ''), ecosystem: 'golang', filePath,
6744
+ isUnpinned: false,
6745
+ isTransitive: true, // every go.sum entry is a resolved (transitive or direct) dep
6746
+ });
6747
+ }
6748
+ return out;
6749
+ }
6750
+
6751
+ // conan.lock: the resolved Conan dependency lockfile. JSON format:
6752
+ // { "version": "0.5", "graph_lock": { "nodes": { "0": {"ref": "openssl/3.0.0@..." }, "1": {...} } } }
6753
+ // or the newer Conan 2.x format with a flat list. Both supported here.
6754
+ function _parseConanLock(text, filePath){
6755
+ const out = [];
6756
+ let d;
6757
+ try { d = JSON.parse(text); } catch { return out; }
6758
+ const refs = new Set();
6759
+ // Conan 1.x graph_lock format.
6760
+ const nodes = d?.graph_lock?.nodes;
6761
+ if (nodes && typeof nodes === 'object') {
6762
+ for (const node of Object.values(nodes)) {
6763
+ if (node && typeof node.ref === 'string') refs.add(node.ref);
6764
+ }
6765
+ }
6766
+ // Conan 2.x lock format: arrays under requires / build_requires / python_requires.
6767
+ for (const k of ['requires', 'build_requires', 'python_requires']) {
6768
+ if (Array.isArray(d?.[k])) for (const r of d[k]) if (typeof r === 'string') refs.add(r);
6769
+ }
6770
+ for (const ref of refs) {
6771
+ // Format: name/version[@user/channel][#revision]
6772
+ const m = ref.match(/^([^/]+)\/([^@#\s]+)/);
6773
+ if (!m) continue;
6774
+ const name = m[1].toLowerCase();
6775
+ const version = m[2];
6776
+ out.push({
6777
+ name, version, group: '', scope: 'required',
6778
+ purl: `pkg:generic/${name}@${version}`, ecosystem: 'system', filePath,
6779
+ isUnpinned: false,
6780
+ isTransitive: true,
6781
+ });
6782
+ }
6783
+ return out;
6784
+ }
6785
+
6786
+ // vcpkg-configuration.json: overlay-ports config for vcpkg. Schema:
6787
+ // { "default-registry": {...}, "registries": [{ "kind": "git", "packages": [...] }] }
6788
+ // We don't have version info here (vcpkg pins via baseline commit), so emit
6789
+ // each named package with version 0.0.0 so OSV at least sees the package
6790
+ // name. Useful for "are you using an EOL/known-malicious overlay registry?"
6791
+ function _parseVcpkgConfiguration(text, filePath){
6792
+ const out = [];
6793
+ let d;
6794
+ try { d = JSON.parse(text); } catch { return out; }
6795
+ const names = new Set();
6796
+ if (Array.isArray(d?.registries)) {
6797
+ for (const reg of d.registries) {
6798
+ if (Array.isArray(reg?.packages)) for (const p of reg.packages) if (typeof p === 'string') names.add(p);
6799
+ }
6800
+ }
6801
+ for (const name of names) {
6802
+ out.push({
6803
+ name: name.toLowerCase(), version: '0.0.0', group: '', scope: 'required',
6804
+ purl: `pkg:generic/${name.toLowerCase()}@0.0.0`, ecosystem: 'system', filePath,
6805
+ isUnpinned: true,
6806
+ });
6807
+ }
6808
+ return out;
6809
+ }
6435
6810
  function parseManifests(allFileContents){
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};
6811
+ const PARSERS={
6812
+ 'package.json':_parsePackageJson,'package-lock.json':_parsePackageLockJson,
6813
+ 'yarn.lock':_parseYarnLock,'pnpm-lock.yaml':_parsePnpmLock,
6814
+ 'requirements.txt':_parseRequirementsTxt,'pyproject.toml':_parsePyprojectToml,
6815
+ 'poetry.lock':_parsePoetryLock,'Pipfile.lock':_parsePipfileLock,
6816
+ 'composer.json':_parseComposerJson,'composer.lock':_parseComposerLock,
6817
+ 'Gemfile':_parseGemfile,'Gemfile.lock':_parseGemfileLock,
6818
+ 'go.mod':_parseGoMod,'go.sum':_parseGoSum,
6819
+ 'Cargo.toml':_parseCargoToml,'Cargo.lock':_parseCargoLock,
6820
+ 'pom.xml':_parsePomXml,'build.gradle':_parseBuildGradle,'build.gradle.kts':_parseBuildGradle,
6821
+ 'pubspec.yaml':_parsePubspecYaml,'pubspec.lock':_parsePubspecLock,
6822
+ 'CMakeLists.txt':_parseCMakeLists,
6823
+ 'conanfile.txt':_parseConanfile,'conan.lock':_parseConanLock,
6824
+ 'vcpkg.json':_parseVcpkgJson,'vcpkg-configuration.json':_parseVcpkgConfiguration,
6825
+ // Maven dependency-tree output. The canonical path is
6826
+ // `target/dependency-tree.txt` (per `mvn dependency:tree -DoutputFile=...`)
6827
+ // but users sometimes commit it as `dependency-tree.txt` at repo root.
6828
+ 'dependency-tree.txt':_parseMavenDependencyTree,
6829
+ };
6437
6830
  const out=[],seen=new Set();
6438
6831
  for(const[fp,content]of Object.entries(allFileContents)){
6439
6832
  const base=fp.split('/').pop();
@@ -6558,51 +6951,74 @@ async function queryOSV(components,allFileContents){
6558
6951
  }
6559
6952
  }
6560
6953
 
6561
- for(const[vid,affectedComps]of Object.entries(vulnAffects)){
6562
- let vuln=_osvCacheGet('vuln:'+vid);
6563
- if(!vuln){
6564
- try{
6565
- const resp=await fetch(`https://api.osv.dev/v1/vulns/${vid}`);
6566
- const d=await resp.json();
6567
- const fixedVersions=new Set();
6568
- const osvVulnFunctions=[];
6569
- for(const aff of(d.affected||[])){
6570
- for(const rng of(aff.ranges||[]))for(const ev of(rng.events||[]))if(ev.fixed)fixedVersions.add(ev.fixed);
6571
- const es=aff.ecosystem_specific||aff.database_specific||{};
6572
- if(Array.isArray(es.vulnerable_functions))osvVulnFunctions.push(...es.vulnerable_functions);
6573
- if(Array.isArray(es.imports))for(const imp of es.imports)if(Array.isArray(imp.symbols))osvVulnFunctions.push(...imp.symbols);
6574
- }
6575
- let severity='medium';
6576
- const db=d.database_specific||{};
6577
- if(db.severity)severity=db.severity.toLowerCase()==='moderate'?'medium':db.severity.toLowerCase();
6578
- let cvssVector=null;
6579
- for(const s of(d.severity||[]))if(s.type==='CVSS_V3'||s.type==='CVSS_V4'){cvssVector=s.score;break;}
6580
- // External-identifier exception (TOS compliance note):
6581
- // The domain fragments below are third-party PoC tracker domain names
6582
- // (exploit-db.com, packetstormsecurity.org). They are inputs we match
6583
- // against reference URLs returned by OSV — not output text we generate.
6584
- // Renaming them loses SCA detection of PoC-published CVEs.
6585
- const _KNOWN_PUBLIC_POC_DOMAINS = ['exploit-db','packetstorm','/poc','/0day'];
6586
- const hasKnownAttackRef=(d.references||[]).some(r=>_KNOWN_PUBLIC_POC_DOMAINS.some(x=>(r.url||'').toLowerCase().includes(x)));
6587
- vuln={id:vid,description:(d.summary||d.details||'No description.').slice(0,300),
6588
- fixedVersions:[...fixedVersions].sort(),
6589
- aliases:(d.aliases||[]).filter(a=>a.startsWith('CVE-')),
6590
- osvVulnFunctions:[...new Set(osvVulnFunctions)],
6591
- severity,cvssVector,hasKnownAttackRef};
6592
- _osvCacheSet('vuln:'+vid,vuln);
6593
- }catch(_){continue;}
6594
- }
6595
- for(const comp of affectedComps){
6596
- const cveStr=vuln.aliases.length?` (${vuln.aliases[0]})`:'';
6597
- const fixStr=vuln.fixedVersions.length?vuln.fixedVersions[0]:null;
6598
- results.push({type:'vulnerable_dep',name:comp.name,version:comp.version,ecosystem:comp.ecosystem,
6599
- purl:comp.purl,osvId:vid,cveAliases:vuln.aliases,description:vuln.description,
6600
- fixedVersions:vuln.fixedVersions,severity:vuln.severity,cvssVector:vuln.cvssVector,
6601
- hasKnownAttackRef:vuln.hasKnownAttackRef,osvVulnFunctions:vuln.osvVulnFunctions||[],reachable:comp.reachable,scope:comp.scope,
6602
- file:comp.filePath,
6954
+ // External-identifier exception (TOS compliance note):
6955
+ // The domain fragments below are third-party PoC tracker domain names.
6956
+ // They are inputs we match against reference URLs returned by OSV — not
6957
+ // output text we generate. Renaming them loses SCA detection of
6958
+ // PoC-published CVEs.
6959
+ const _KNOWN_PUBLIC_POC_DOMAINS = ['exploit-db','packetstorm','/poc','/0day'];
6960
+ // Fetch missing vuln details in parallel with a concurrency cap so we don't
6961
+ // open hundreds of sockets on a large dep tree. OSV has no batch endpoint
6962
+ // for /v1/vulns/{id}, so this is the best we can do without API changes.
6963
+ const _VULN_CONCURRENCY = 20;
6964
+ const vulnEntries = Object.entries(vulnAffects);
6965
+ const vulnDetails = new Map(); // vid → vuln object (from cache or fresh)
6966
+ // First, drain cache hits with no network involvement.
6967
+ const fetchQueue = [];
6968
+ for (const [vid, _comps] of vulnEntries) {
6969
+ const cached = _osvCacheGet('vuln:' + vid);
6970
+ if (cached) vulnDetails.set(vid, cached);
6971
+ else fetchQueue.push(vid);
6972
+ }
6973
+ // Then fetch uncached vulns with bounded concurrency.
6974
+ async function _fetchOneVuln(vid){
6975
+ try {
6976
+ const resp = await fetch(`https://api.osv.dev/v1/vulns/${vid}`);
6977
+ const d = await resp.json();
6978
+ const fixedVersions = new Set();
6979
+ const osvVulnFunctions = [];
6980
+ for (const aff of (d.affected || [])) {
6981
+ for (const rng of (aff.ranges || [])) for (const ev of (rng.events || [])) if (ev.fixed) fixedVersions.add(ev.fixed);
6982
+ const es = aff.ecosystem_specific || aff.database_specific || {};
6983
+ if (Array.isArray(es.vulnerable_functions)) osvVulnFunctions.push(...es.vulnerable_functions);
6984
+ if (Array.isArray(es.imports)) for (const imp of es.imports) if (Array.isArray(imp.symbols)) osvVulnFunctions.push(...imp.symbols);
6985
+ }
6986
+ let severity = 'medium';
6987
+ const db = d.database_specific || {};
6988
+ if (db.severity) severity = db.severity.toLowerCase() === 'moderate' ? 'medium' : db.severity.toLowerCase();
6989
+ let cvssVector = null;
6990
+ for (const s of (d.severity || [])) if (s.type === 'CVSS_V3' || s.type === 'CVSS_V4') { cvssVector = s.score; break; }
6991
+ const hasKnownAttackRef = (d.references || []).some(r => _KNOWN_PUBLIC_POC_DOMAINS.some(x => (r.url || '').toLowerCase().includes(x)));
6992
+ const vuln = { id: vid,
6993
+ description: (d.summary || d.details || 'No description.').slice(0, 300),
6994
+ fixedVersions: [...fixedVersions].sort(),
6995
+ aliases: (d.aliases || []).filter(a => a.startsWith('CVE-')),
6996
+ osvVulnFunctions: [...new Set(osvVulnFunctions)],
6997
+ severity, cvssVector, hasKnownAttackRef };
6998
+ _osvCacheSet('vuln:' + vid, vuln);
6999
+ return [vid, vuln];
7000
+ } catch (_) { return null; }
7001
+ }
7002
+ for (let i = 0; i < fetchQueue.length; i += _VULN_CONCURRENCY) {
7003
+ const batch = fetchQueue.slice(i, i + _VULN_CONCURRENCY);
7004
+ const settled = await Promise.all(batch.map(_fetchOneVuln));
7005
+ for (const r of settled) if (r) vulnDetails.set(r[0], r[1]);
7006
+ }
7007
+ // Materialize results in the original vulnAffects iteration order.
7008
+ for (const [vid, affectedComps] of vulnEntries) {
7009
+ const vuln = vulnDetails.get(vid);
7010
+ if (!vuln) continue;
7011
+ for (const comp of affectedComps) {
7012
+ const cveStr = vuln.aliases.length ? ` (${vuln.aliases[0]})` : '';
7013
+ const fixStr = vuln.fixedVersions.length ? vuln.fixedVersions[0] : null;
7014
+ results.push({ type: 'vulnerable_dep', name: comp.name, version: comp.version, ecosystem: comp.ecosystem,
7015
+ purl: comp.purl, osvId: vid, cveAliases: vuln.aliases, description: vuln.description,
7016
+ fixedVersions: vuln.fixedVersions, severity: vuln.severity, cvssVector: vuln.cvssVector,
7017
+ hasKnownAttackRef: vuln.hasKnownAttackRef, osvVulnFunctions: vuln.osvVulnFunctions || [], reachable: comp.reachable, scope: comp.scope,
7018
+ file: comp.filePath,
6603
7019
  // kept for generateRecs() compat
6604
- advisory:`${vid}${cveStr}, ${vuln.description}`,
6605
- range:fixStr?`< ${fixStr}`:'see advisory'});
7020
+ advisory: `${vid}${cveStr}, ${vuln.description}`,
7021
+ range: fixStr ? `< ${fixStr}` : 'see advisory' });
6606
7022
  }
6607
7023
  }
6608
7024
 
@@ -6859,6 +7275,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
6859
7275
  aF.push(...scanKotlin(p,c));
6860
7276
  aF.push(...scanRuby(p,c));
6861
7277
  aF.push(...scanPhp(p,c));
7278
+ // Integration block: scaffolded SAST scanners. Gated by env var.
7279
+ if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
7280
+ if (process.env.AGENTIC_SECURITY_NO_LLM_APP !== '1') aF.push(...scanLlmApp(p,c));
7281
+ if (process.env.AGENTIC_SECURITY_NO_MOBILE !== '1') aF.push(...scanMobile(p,c));
7282
+ if (process.env.AGENTIC_SECURITY_NO_PQC !== '1') aF.push(...scanPqc(p,c));
7283
+ if (process.env.AGENTIC_SECURITY_NO_WEB3_ADV!== '1') aF.push(...scanWeb3Advanced(p,c));
7284
+ if (process.env.AGENTIC_SECURITY_NO_DAPP !== '1') aF.push(...scanDappFrontend(p,c));
7285
+ if (process.env.AGENTIC_SECURITY_NO_CLOUD_IAM!== '1') aF.push(...scanCloudIam(p,c));
7286
+ if (process.env.AGENTIC_SECURITY_NO_K8S_ADM !== '1') aF.push(...scanK8sAdmission(p,c));
7287
+ if (process.env.AGENTIC_SECURITY_NO_CRYPTO_PROTO !== '1') aF.push(...scanCryptoProtocol(p,c));
7288
+ if (process.env.AGENTIC_SECURITY_NO_ML_SUPPLY !== '1') aF.push(...scanMlSupplyChain(p,c));
7289
+ }
6862
7290
  const _ftElapsed=Date.now()-_ft0;
6863
7291
  if(_ftElapsed>_perFileTimeoutMs){aF.push({id:`file-timeout:${p}`,file:p,line:0,vuln:`File analysis exceeded ${_perFileTimeoutMs}ms (${_ftElapsed}ms)`,severity:'info',parser:'ENGINE',confidence:0.5,_timeout:true});_filesTimedOut++;}
6864
7292
  _fileTimings.push({file:p,ms:_ftElapsed});
@@ -7033,16 +7461,48 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7033
7461
  const dd=(a,k)=>[...new Map(a.map(x=>[k(x),x])).values()];
7034
7462
  // 0.6.0 Feat-1: annotate function-level reachability on SCA findings
7035
7463
  try { _annotateFunctionReachability(supplyChain,dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`).map(r=>({...r})),callGraph,fc); } catch(_) {}
7036
- // Reachability tier classification for SCA findings
7037
- for(const sc of supplyChain||[]){
7038
- if(sc.type!=='vulnerable_dep')continue;
7039
- if(sc.functionReachable==='reachable')sc.reachabilityTier='function-reachable';
7040
- else if(sc.functionReachable==='unreachable')sc.reachabilityTier='unreachable';
7041
- else if(sc.reachable)sc.reachabilityTier='import-reachable';
7042
- else if(sc.isBuildOnly||sc.scope==='optional'&&!sc.reachable){sc.reachabilityTier='build-only';sc.isBuildOnly=true;if(sc.severity==='critical')sc.severity='medium';else if(sc.severity==='high')sc.severity='low';}
7043
- else if(sc.scope==='required')sc.reachabilityTier='manifest-only';
7044
- else sc.reachabilityTier='transitive-only';
7045
- if(sc.reachabilityTier==='transitive-only'){sc.unreachable=true;sc._reachabilityDemoted=true;}
7464
+ // Reachability tier classification for SCA findings. Order matters —
7465
+ // each tier represents a narrower (more certain) form of reachability,
7466
+ // so we check from most-specific to least-specific.
7467
+ //
7468
+ // route-reachable-via-function (Phase 2 / Item 4): the vulnerable
7469
+ // function is called AND that call site is rooted in (or
7470
+ // transitively-called from) a route handler. Highest urgency.
7471
+ // function-reachable: the vulnerable function is called, but the call
7472
+ // chain does not reach a route handler in <=4 hops. Real but lower
7473
+ // priority than route-reachable.
7474
+ // unreachable: vulnerable function never called from the project.
7475
+ // import-reachable: package is imported but no vulnerable function call
7476
+ // was identified (OSV may not list function-level data).
7477
+ // build-only / manifest-only / transitive-only: progressively weaker
7478
+ // signals; the dep exists but we can't confirm any use of it.
7479
+ for (const sc of supplyChain || []) {
7480
+ if (sc.type !== 'vulnerable_dep') continue;
7481
+ if (sc.functionReachable === 'reachable' && sc.routeReachable === true) {
7482
+ sc.reachabilityTier = 'route-reachable-via-function';
7483
+ } else if (sc.functionReachable === 'reachable') {
7484
+ sc.reachabilityTier = 'function-reachable';
7485
+ } else if (sc.functionReachable === 'unreachable') {
7486
+ sc.reachabilityTier = 'unreachable';
7487
+ } else if (sc.reachable) {
7488
+ sc.reachabilityTier = 'import-reachable';
7489
+ } else if (sc.isBuildOnly || (sc.scope === 'optional' && !sc.reachable)) {
7490
+ sc.reachabilityTier = 'build-only';
7491
+ sc.isBuildOnly = true;
7492
+ if (sc.severity === 'critical') sc.severity = 'medium';
7493
+ else if (sc.severity === 'high') sc.severity = 'low';
7494
+ } else if (sc.scope === 'required') {
7495
+ sc.reachabilityTier = 'manifest-only';
7496
+ } else {
7497
+ sc.reachabilityTier = 'transitive-only';
7498
+ }
7499
+ if (sc.reachabilityTier === 'transitive-only') { sc.unreachable = true; sc._reachabilityDemoted = true; }
7500
+ // Mark as the strongest "vulnerable function actually invoked" signal so
7501
+ // posture/reachability-filter can preserve full severity on this tier
7502
+ // while demoting lower tiers.
7503
+ if (sc.reachabilityTier === 'route-reachable-via-function') {
7504
+ sc.routeReachableViaFunction = true;
7505
+ }
7046
7506
  }
7047
7507
  // Early dedup: collapse duplicates BEFORE the annotation pipeline to reduce work.
7048
7508
  try{const _earlyMap=new Map();for(const f of aF){const k=`${f.file||''}:${f.line||0}:${f.vuln||''}`;if(!_earlyMap.has(k))_earlyMap.set(k,f);}aF.length=0;aF.push(..._earlyMap.values());}catch(_){}
@@ -7051,6 +7511,44 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7051
7511
  const vulnsByKey={};for(const sc of supplyChain.filter(s=>s.type==='vulnerable_dep')){const k=`${sc.ecosystem}:${sc.name}:${sc.version}`;if(!vulnsByKey[k])vulnsByKey[k]=[];vulnsByKey[k].push(sc);}
7052
7512
  const attackResult=computeAttackPathComponents(aF,components,reach.byFile);
7053
7513
  for(const[key,paths]of attackResult.pathsByKey){const[eco,name,...vp]=key.split(':');const ver=vp.join(':');for(const f of paths){if(!f.linkedComponents)f.linkedComponents=[];if(!f.linkedComponents.some(c=>c.name===name&&c.ecosystem===eco))f.linkedComponents.push({ecosystem:eco,name,version:ver});}}
7514
+ // Phase 4 / Item 8 of the SCA improvement plan: reverse pointer.
7515
+ // For each (component-key, sastFindings[]) pair from attackResult, find
7516
+ // the matching supplyChain finding(s) and stamp linkedFindings[] on them
7517
+ // — each entry pointing at the SAST finding that taint-flows to that
7518
+ // dep. Also persist a one-line chain narrative so /show-findings
7519
+ // --chains can consume it without re-invoking the synthesizer agent.
7520
+ // Size-capped to MAX_LINKED_FINDINGS per SCA finding (premortem 3R-7
7521
+ // — don't unbounded-grow last-scan.json on a noisy attack graph).
7522
+ const MAX_LINKED_FINDINGS = 10;
7523
+ for (const [key, paths] of attackResult.pathsByKey) {
7524
+ const scaFindings = vulnsByKey[key] || [];
7525
+ if (!scaFindings.length || !paths.length) continue;
7526
+ for (const sc of scaFindings) {
7527
+ if (!Array.isArray(sc.linkedFindings)) sc.linkedFindings = [];
7528
+ if (!Array.isArray(sc.chainNarratives)) sc.chainNarratives = [];
7529
+ for (const sastF of paths) {
7530
+ if (sc.linkedFindings.length >= MAX_LINKED_FINDINGS) break;
7531
+ const linkEntry = {
7532
+ findingId: sastF.id || null,
7533
+ vuln: sastF.vuln || sastF.title || null,
7534
+ severity: sastF.severity || null,
7535
+ file: sastF.file || sastF.sink?.file || null,
7536
+ line: sastF.sink?.line ?? sastF.line ?? null,
7537
+ };
7538
+ // Dedupe by (findingId, file:line, vuln) so re-runs of this block
7539
+ // (we hit it once per attack-graph traversal) don't grow the array.
7540
+ const dedupeKey = `${linkEntry.findingId}|${linkEntry.file}:${linkEntry.line}|${linkEntry.vuln}`;
7541
+ if (sc.linkedFindings.some(e => `${e.findingId}|${e.file}:${e.line}|${e.vuln}` === dedupeKey)) continue;
7542
+ sc.linkedFindings.push(linkEntry);
7543
+ // Narrative: short, human-readable. Avoids re-invoking the
7544
+ // synthesizer agent for the common case where the chain is just
7545
+ // "SAST X reaches vulnerable dep Y".
7546
+ const narrative = `${linkEntry.vuln || 'sink'} on ${linkEntry.file || '?'}:${linkEntry.line || 0} → ${sc.name}@${sc.version} (${sc.osvId || (sc.cveAliases && sc.cveAliases[0]) || 'unknown CVE'})`;
7547
+ if (!sc.chainNarratives.includes(narrative)) sc.chainNarratives.push(narrative);
7548
+ }
7549
+ sc.hasLinkedSastFindings = sc.linkedFindings.length > 0;
7550
+ }
7551
+ }
7054
7552
  const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
7055
7553
  try{aF.push(...scanDbTaintCrossFile(fc));}catch(_){}
7056
7554
  try{aF.push(...scanStoredPromptInjectionCrossFile(fc));}catch(_){}
@@ -7182,6 +7680,104 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7182
7680
  _runAnnotator("annotateFeatureFlagGating", () => { annotateFeatureFlagGating(finalFindings, fc, { scanRoot }); });
7183
7681
  // v3 next-gen: composite mitigation verdict consumes every prod signal above.
7184
7682
  _runAnnotator("annotateMitigationComposite", () => { annotateMitigationComposite(finalFindings); });
7683
+ // Composite risk score (0..100 derived ordinal). Must run AFTER mitigation
7684
+ // composite + exploitability + toxicityScore so it sees the final values.
7685
+ // Used by agents and UI as the canonical sort key for "which finding first."
7686
+ _runAnnotator("annotateCompositeRisk", () => { annotateCompositeRisk(finalFindings); });
7687
+
7688
+ // ── World-class integration block ─────────────────────────────────────
7689
+ // Each annotator is opt-in via env var and try/catch wrapped. They run
7690
+ // AFTER composite risk so they can read the canonical risk ordinal but
7691
+ // BEFORE crown-jewel / persona scoring so demotions are honored.
7692
+ if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
7693
+ // Cross-service taint annotation reads .agentic-security/services.yml
7694
+ // and bumps severity on cross-service-reachable findings.
7695
+ if (process.env.AGENTIC_SECURITY_NO_CROSS_SERVICE !== '1') {
7696
+ _runAnnotator("runCrossServiceTaint", () => { runCrossServiceTaint(scanRoot, finalFindings); });
7697
+ }
7698
+ // Runtime correlation: demotes findings whose paths were unobserved
7699
+ // in production eBPF traces (when a trace file is present).
7700
+ if (process.env.AGENTIC_SECURITY_NO_RUNTIME_CORRELATION !== '1') {
7701
+ _runAnnotator("annotateRuntimeCorrelation", async () => { await annotateRuntimeCorrelation(scanRoot, finalFindings); });
7702
+ }
7703
+ // Triage learning: applies per-(project, family, file-glob) calibration
7704
+ // from prior wont-fix / false-positive decisions.
7705
+ if (process.env.AGENTIC_SECURITY_NO_TRIAGE_LEARNING !== '1') {
7706
+ _runAnnotator("applyLearnedCalibration", () => { applyLearnedCalibration(scanRoot, finalFindings); });
7707
+ }
7708
+ // Formal verification: CBMC for C/C++, MIRI for Rust. Opt-in via
7709
+ // AGENTIC_SECURITY_FORMAL=1 (off by default — requires external tools).
7710
+ if (process.env.AGENTIC_SECURITY_FORMAL === '1') {
7711
+ _runAnnotator("annotateFormalVerification", async () => { await annotateFormalVerification(finalFindings, fc, {}); });
7712
+ }
7713
+ // SMT path feasibility: Z3-backed proof of reachability. Opt-in via
7714
+ // AGENTIC_SECURITY_SMT_FEASIBILITY=1.
7715
+ if (process.env.AGENTIC_SECURITY_SMT_FEASIBILITY === '1') {
7716
+ _runAnnotator("annotatePathFeasibility", async () => { await annotatePathFeasibility(finalFindings, {}); });
7717
+ }
7718
+ // Privacy / PII taint: emits pii-exposure findings + DPIA artifact.
7719
+ if (process.env.AGENTIC_SECURITY_NO_PRIVACY !== '1') {
7720
+ _runAnnotator("annotatePrivacyTaint", () => {
7721
+ // Build a minimal IR map from fc; the privacy taint module needs
7722
+ // per-file content + a coarse decls/calls list. We construct it
7723
+ // from existing finding sources rather than re-parsing every file.
7724
+ const minimalIR = new Map();
7725
+ for (const [fp, content] of Object.entries(fc || {})) {
7726
+ if (typeof content !== 'string') continue;
7727
+ minimalIR.set(fp, { _content: content, decls: [], calls: [] });
7728
+ }
7729
+ const r = annotatePrivacyTaint(minimalIR);
7730
+ if (r && Array.isArray(r.findings)) finalFindings.push(...r.findings);
7731
+ // Persist the DPIA scaffold for compliance review.
7732
+ if (r && r.piiFields) {
7733
+ try {
7734
+ const dpia = emitDpiaArtifact(r.piiFields, r.findings || []);
7735
+ fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'dpia.md'), dpia);
7736
+ } catch (_) {}
7737
+ }
7738
+ });
7739
+ }
7740
+ // Attack-taxonomy annotation: stamps each finding with MITRE ATT&CK,
7741
+ // ATLAS, D3FEND, kill-chain stage, and CAPEC IDs so downstream SIEM /
7742
+ // SOAR systems can correlate with existing detection rules.
7743
+ if (process.env.AGENTIC_SECURITY_NO_ATTACK_TAX !== '1') {
7744
+ _runAnnotator("annotateAttackTaxonomy", () => { annotateAttackTaxonomy(finalFindings); });
7745
+ }
7746
+ // Triage memory — demote findings whose (family, dir) bucket was
7747
+ // previously marked wont-fix or false-positive in this project.
7748
+ if (process.env.AGENTIC_SECURITY_NO_TRIAGE_MEMORY !== '1') {
7749
+ _runAnnotator("suppressByPastDecisions", () => { suppressByPastDecisions(scanRoot, finalFindings); });
7750
+ }
7751
+ // Intent-aware FP suppression — demote findings on files marked as
7752
+ // intentionally vulnerable (sandbox/CTF/tutorial/example/etc.).
7753
+ if (process.env.AGENTIC_SECURITY_NO_INTENT_CTX !== '1') {
7754
+ _runAnnotator("suppressByIntent", () => { suppressByIntent(scanRoot, finalFindings); });
7755
+ }
7756
+ // Git history — stamp each finding with introducedBy / introducedIn /
7757
+ // originatingPrompt by running `git blame` on the finding's line.
7758
+ if (process.env.AGENTIC_SECURITY_NO_GIT_HISTORY !== '1') {
7759
+ _runAnnotator("annotateGitHistory", () => { annotateGitHistory(scanRoot, finalFindings); });
7760
+ }
7761
+ // Threat-model grounding — bump severity on crown-jewels, demote
7762
+ // out-of-scope, tag compliance regimes, stamp attacker profile.
7763
+ if (process.env.AGENTIC_SECURITY_NO_THREAT_MODEL_GROUNDING !== '1') {
7764
+ _runAnnotator("applyThreatModel", () => { applyThreatModel(scanRoot, finalFindings); });
7765
+ }
7766
+ // Cross-repo pattern propagation — surface sibling-repo fixes and
7767
+ // triage decisions for the same family from this developer's history.
7768
+ if (process.env.AGENTIC_SECURITY_NO_CROSS_REPO !== '1') {
7769
+ _runAnnotator("annotateCrossRepoSignals", () => { annotateCrossRepoSignals(scanRoot, finalFindings); });
7770
+ }
7771
+ // Risk-in-dollars — combine EPSS + crown-jewel + reachability into an
7772
+ // expected-value-of-exploitation USD figure per finding.
7773
+ if (process.env.AGENTIC_SECURITY_NO_RISK_DOLLARS !== '1') {
7774
+ _runAnnotator("annotateRiskDollars", () => { annotateRiskDollars(scanRoot, finalFindings); });
7775
+ }
7776
+ // Time-to-fix — estimate engineering hours per finding.
7777
+ if (process.env.AGENTIC_SECURITY_NO_TIME_TO_FIX !== '1') {
7778
+ _runAnnotator("annotateTimeToFix", () => { annotateTimeToFix(scanRoot, finalFindings); });
7779
+ }
7780
+ }
7185
7781
  // v3 next-gen: crown-jewel mapping (FR-PROD-5) — score each file/finding by
7186
7782
  // business impact. Must run before persona prioritization (which uses it).
7187
7783
  _runAnnotator("annotateCrownJewelScores", () => { annotateCrownJewelScores(finalFindings, fc); });
@@ -7410,11 +8006,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7410
8006
  const _toxCtx={routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`).map(r=>({...r})),supplyChain,hasCloudCreds:_hasCloudCreds};
7411
8007
  try{finalFindings.forEach(f=>scoreToxicity(f,_toxCtx));}catch(_){}
7412
8008
  for(const sc of supplyChain||[]){try{scoreToxicity(sc,_toxCtx);}catch(_){}}
8009
+ // Composite risk for the supplyChain bucket too. The SAST pass at
8010
+ // annotateCompositeRisk above runs before scoreToxicity (which only
8011
+ // touches finalFindings in the same line), so this is the catch-up call
8012
+ // for the SCA bucket — must happen after KEV / EPSS / scoreToxicity on
8013
+ // supplyChain so it sees the populated signals.
8014
+ try { annotateCompositeRisk(supplyChain); } catch (_) {}
8015
+ // And re-annotate finalFindings here so they pick up the toxicityScore
8016
+ // that was just set. Idempotent: composite-risk derives fresh from the
8017
+ // current field values on each call.
8018
+ try { annotateCompositeRisk(finalFindings); } catch (_) {}
7413
8019
  // 0.9.0 Feat-18: OSSF Scorecard enrichment (opt-in via AGENTIC_SECURITY_SCORECARD=1)
7414
8020
  try { await _enrichWithScorecard(annotatedComponents); }
7415
8021
  catch (e) { _annotatorErrors.push({ phase: '_enrichWithScorecard', err: String((e && e.message) || e) }); }
7416
8022
  // 0.8.0 Feat-10: license policy
7417
8023
  try{const lp=loadLicensePolicy(scanRoot);if(lp){const lv=evaluateLicensePolicy(annotatedComponents,lp);aLogic.push(...lv);}}catch(_){}
8024
+ // Phase 4 / Item 7 of the SCA improvement plan: load sca-policy.yml and
8025
+ // apply accept-risk / SLA / major-version-freeze rules. supplyChain
8026
+ // findings get suppressed/tagged in place.
8027
+ try { const sp = loadScaPolicy(scanRoot); if (sp && !sp._error) applyScaPolicy(supplyChain, sp); } catch (_) {}
7418
8028
  // 0.9.0 Feat-15: dep confusion
7419
8029
  try{const dc=detectDepConfusion(annotatedComponents,scanRoot);aF.push(...dc);}catch(_){}
7420
8030
  // Deployment-platform security checklist
@@ -7558,8 +8168,95 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7558
8168
  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
8169
  // SCA transitive dedup: collapse duplicate CVEs across dep chains
7560
8170
  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(_){}
8171
+ // ── World-class post-scan artifact emitters ──────────────────────────
8172
+ // Each is opt-in via env var. They produce machine-readable artifacts
8173
+ // (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
8174
+ // sbom-history/<sha>.json, exploit-bundles/) under .agentic-security/.
8175
+ let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
8176
+ _complianceReport = null, _exploitBundles = null, _pqcPlan = null,
8177
+ _licenseGraph = null, _attributions = null, _taxonomySummary = null;
8178
+ if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
8179
+ // Threat model — STRIDE + entities + attack trees rooted in findings.
8180
+ if (process.env.AGENTIC_SECURITY_NO_THREAT_MODEL !== '1') {
8181
+ try {
8182
+ _threatModel = buildAutoThreatModel({
8183
+ findings: finalFindings, routes: aR, supplyChain,
8184
+ });
8185
+ persistAutoThreatModel(scanRoot, _threatModel);
8186
+ } catch (_) {}
8187
+ }
8188
+ // API contract — undocumented endpoints / missing-auth flags.
8189
+ if (process.env.AGENTIC_SECURITY_NO_API_CONTRACT !== '1') {
8190
+ try { _apiContractFindings = runApiContractScan(scanRoot, aR) || []; finalFindings.push(..._apiContractFindings); } catch (_) {}
8191
+ }
8192
+ // SBOM diff — drift detection across releases.
8193
+ if (process.env.AGENTIC_SECURITY_NO_SBOM_DIFF !== '1') {
8194
+ try {
8195
+ _sbomDiff = runSbomDiff(scanRoot, annotatedComponents || []);
8196
+ if (_sbomDiff && Array.isArray(_sbomDiff.findings)) finalFindings.push(..._sbomDiff.findings);
8197
+ } catch (_) {}
8198
+ }
8199
+ // Sigstore provenance — opt-in (requires network + flag).
8200
+ if (process.env.AGENTIC_SECURITY_SIGSTORE === '1') {
8201
+ try { /* fire-and-forget async — don't block scan completion */ annotateProvenance(supplyChain, annotatedComponents).catch(()=>{}); } catch (_) {}
8202
+ }
8203
+ // Compliance evidence — auto-generate from policy file if present.
8204
+ if (process.env.AGENTIC_SECURITY_NO_COMPLIANCE !== '1') {
8205
+ try {
8206
+ const policy = loadCompliancePolicy(scanRoot);
8207
+ if (policy && !policy._error) {
8208
+ _complianceReport = verifyCompliancePolicy(policy, { scanRoot, findings: finalFindings });
8209
+ emitComplianceJsonLd(_complianceReport, scanRoot);
8210
+ emitComplianceMarkdown(_complianceReport, scanRoot);
8211
+ }
8212
+ } catch (_) {}
8213
+ }
8214
+ // License graph — transitive copyleft + distribution-mode + relicense.
8215
+ if (process.env.AGENTIC_SECURITY_NO_LICENSE_GRAPH !== '1') {
8216
+ try {
8217
+ const lgPolicy = loadLicenseGraphPolicy(scanRoot);
8218
+ _licenseGraph = analyzeLicenseGraph(annotatedComponents || [], lgPolicy);
8219
+ if (_licenseGraph && Array.isArray(_licenseGraph.findings)) finalFindings.push(..._licenseGraph.findings);
8220
+ } catch (_) {}
8221
+ }
8222
+ // Attributions: emit ATTRIBUTIONS.md (and NOTICE if Apache deps present).
8223
+ if (process.env.AGENTIC_SECURITY_NO_ATTRIBUTIONS !== '1') {
8224
+ try {
8225
+ _attributions = generateAttributions(annotatedComponents || []);
8226
+ if (_attributions && _attributions.componentCount) persistAttributions(scanRoot, _attributions);
8227
+ } catch (_) {}
8228
+ }
8229
+ // Attack taxonomy summary — aggregates ATT&CK / ATLAS / kill-chain
8230
+ // distribution over all findings for the report layer.
8231
+ if (process.env.AGENTIC_SECURITY_NO_ATTACK_TAX !== '1') {
8232
+ try { _taxonomySummary = summarizeTaxonomy(finalFindings); } catch (_) {}
8233
+ }
8234
+ // PQC migration plan — aggregates pqc-migration findings into a
8235
+ // structured plan (.agentic-security/pqc-migration-plan.{json,md}).
8236
+ if (process.env.AGENTIC_SECURITY_NO_PQC_PLAN !== '1') {
8237
+ try {
8238
+ _pqcPlan = buildPqcPlan(finalFindings);
8239
+ if (_pqcPlan) persistPqcPlan(scanRoot, _pqcPlan);
8240
+ } catch (_) {}
8241
+ }
8242
+ // Exploit bundles — per-family PoC + Jest + pytest + remediation for
8243
+ // top-N critical/high findings.
8244
+ if (process.env.AGENTIC_SECURITY_NO_EXPLOIT_BUNDLES !== '1') {
8245
+ try {
8246
+ const bundles = generateExploitBundles(finalFindings, { maxBundles: 25 });
8247
+ if (bundles.size) {
8248
+ _exploitBundles = {};
8249
+ for (const [id, b] of bundles) _exploitBundles[id] = b;
8250
+ const bundlePath = path.join(scanRoot, '.agentic-security', 'exploit-bundles.json');
8251
+ try { fs.mkdirSync(path.dirname(bundlePath), { recursive: true }); } catch {}
8252
+ try { fs.writeFileSync(bundlePath, JSON.stringify(_exploitBundles, null, 2)); } catch {}
8253
+ }
8254
+ } catch (_) {}
8255
+ }
8256
+ }
8257
+
7561
8258
  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}};
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};}
8259
+ 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,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
7563
8260
 
7564
8261
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
7565
8262
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -7998,6 +8695,7 @@ export {
7998
8695
  queryOSV, queryRegistries, computeAttackPathComponents,
7999
8696
  markUsedVulnFunctions, dedupeFindingsWithEvidence, scoreTriage,
8000
8697
  _enrichWithScorecard, scoreToxicity, _enrichWithKEV, _loadKEVCatalog,
8698
+ _enrichWithEPSS, _fetchEPSSBatch,
8001
8699
  classifyOrphans, classifyField, classifyEndpoint, shouldScan,
8002
8700
  _isFalsePositiveCredential, _detectSafeSinkShape,
8003
8701
  _loadCustomRules, _isCustomSuppressed, _isPathIgnored,