@clear-capabilities/agentic-security-scanner 0.128.1 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +209 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +2 -2
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/830.index.js +1 -1
  13. package/dist/agentic-security.mjs +113 -162
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +22 -14
  16. package/src/dataflow/CLAUDE.md +4 -1
  17. package/src/dataflow/async-sequencing.js +8 -3
  18. package/src/dataflow/catalog.js +278 -11
  19. package/src/dataflow/cross-repo.js +1 -1
  20. package/src/dataflow/cross-service-taint.js +1 -1
  21. package/src/dataflow/engine.js +182 -61
  22. package/src/dataflow/ifds.js +10 -5
  23. package/src/dataflow/index.js +15 -3
  24. package/src/dataflow/points-to.js +8 -2
  25. package/src/dataflow/proof-gate.js +7 -0
  26. package/src/dataflow/sanitizer-gate.js +89 -0
  27. package/src/dataflow/tabulation.js +14 -3
  28. package/src/engine.js +154 -7
  29. package/src/integrations/index.js +1 -1
  30. package/src/ir/CLAUDE.md +49 -4
  31. package/src/ir/call-sites.js +66 -0
  32. package/src/ir/callgraph.js +174 -7
  33. package/src/ir/class-hierarchy.js +22 -2
  34. package/src/ir/index.js +138 -51
  35. package/src/ir/ir-stats.js +126 -0
  36. package/src/ir/parser-cpp.js +829 -0
  37. package/src/ir/parser-cs.js +4 -1
  38. package/src/ir/parser-go.js +4 -1
  39. package/src/ir/parser-js.js +5 -1
  40. package/src/ir/parser-kt.js +4 -1
  41. package/src/ir/parser-php.js +10 -3
  42. package/src/ir/parser-py-cst.js +62 -10
  43. package/src/ir/tree-sitter-loader.js +13 -1
  44. package/src/llm-validator/index.js +9 -2
  45. package/src/llm-validator/redact.js +157 -0
  46. package/src/posture/CLAUDE.md +115 -0
  47. package/src/posture/accuracy-scorecard.js +317 -0
  48. package/src/posture/api-contract.js +1 -1
  49. package/src/posture/attestation.js +199 -0
  50. package/src/posture/auditor-walkthrough.js +12 -3
  51. package/src/posture/compliance-policy.js +1 -1
  52. package/src/posture/cross-lang-openapi.js +1 -1
  53. package/src/posture/custom-rules.js +1 -1
  54. package/src/posture/execution-proof.js +52 -0
  55. package/src/posture/exploitability-probability.js +1 -1
  56. package/src/posture/falsification.js +45 -1
  57. package/src/posture/fix-verify.js +55 -2
  58. package/src/posture/license-policy.js +1 -1
  59. package/src/posture/profile.js +1 -1
  60. package/src/posture/proof-tier.js +33 -0
  61. package/src/posture/relevance.js +379 -0
  62. package/src/posture/rule-overrides.js +1 -1
  63. package/src/posture/sca-policy.js +1 -1
  64. package/src/posture/scan-checkpoint.js +277 -0
  65. package/src/posture/suppressions.js +1 -1
  66. package/src/posture/test-runner.js +147 -0
  67. package/src/posture/verification-separation.js +131 -0
  68. package/src/report/index.js +11 -0
  69. package/src/runScan.js +3 -1
  70. package/src/sandbox/CLAUDE.md +218 -0
  71. package/src/sandbox/backend-disabled.js +14 -0
  72. package/src/sandbox/backend-namespace.js +83 -0
  73. package/src/sandbox/backend-userspace.js +100 -0
  74. package/src/sandbox/capabilities.js +53 -0
  75. package/src/sandbox/index.js +30 -0
  76. package/src/sandbox/limits.js +42 -0
  77. package/src/sandbox/result.js +104 -0
  78. package/src/sca/dep-confusion.js +1 -1
  79. package/src/util/yaml.js +24 -0
@@ -16,6 +16,10 @@
16
16
  // the deterministic core runs fully offline.
17
17
 
18
18
  import { isValidSanitizerFor } from '../dataflow/sanitizer-proof.js';
19
+ import {
20
+ recordProducer, assertSeparation, recordVerdict, consensusOf, producerIdOf,
21
+ VERIFIER_FALSIFICATION, VERIFIER_LLM_REVIEW,
22
+ } from './verification-separation.js';
19
23
 
20
24
  const DEMOTE_FACTOR = 0.4; // mirror proof-gate.js
21
25
  const TIERS = ['low', 'medium', 'high']; // confidence / exploitability tier order
@@ -76,6 +80,15 @@ export function classifyFinding(finding, fileContents) {
76
80
  return { verdict: 'survived', reasons: ['no context-matched control found between source and sink'] };
77
81
  }
78
82
 
83
+ // Map a falsification-style verdict onto the verification vocabulary.
84
+ // 'blocked'/'refuted' = the finding was disproved on this lens; 'survived' =
85
+ // the attempt to disprove it failed, so the finding stands on this lens.
86
+ function _verdictFor(v) {
87
+ if (v === 'blocked' || v === 'refuted' || v === 'false-positive') return 'refuted';
88
+ if (v === 'survived' || v === 'upheld' || v === 'true-positive') return 'upheld';
89
+ return 'undecided';
90
+ }
91
+
79
92
  /**
80
93
  * Default-on annotator. Adds `finding.falsification = { verdict, reasons }` to
81
94
  * every taint-style finding; demotes + quarantines the ones falsified as blocked.
@@ -95,6 +108,23 @@ export function annotateFalsification(findings, fileContents, opts = {}) {
95
108
  catch { res = { verdict: 'unproven', reasons: ['classification error'] }; }
96
109
  f.falsification = { verdict: res.verdict, reasons: res.reasons };
97
110
 
111
+ // R7 — enforced separation. The detector produced this finding; the
112
+ // falsification pass is a *different* party, and records its verdict only
113
+ // after the separation check passes. Recall-preserving: a 'refuted'
114
+ // verdict is recorded, never acted on by deletion or severity change.
115
+ try {
116
+ recordProducer(f, producerIdOf(f));
117
+ if (assertSeparation(f, VERIFIER_FALSIFICATION).ok) {
118
+ recordVerdict(f, {
119
+ verifierId: VERIFIER_FALSIFICATION,
120
+ lens: 'control-flow',
121
+ verdict: _verdictFor(res.verdict),
122
+ reason: res.reasons && res.reasons[0],
123
+ });
124
+ }
125
+ f.verification.consensus = consensusOf(f);
126
+ } catch { /* verification bookkeeping is advisory; never break the scan */ }
127
+
98
128
  if (res.verdict === 'blocked') {
99
129
  f.quarantined = true;
100
130
  if (typeof f.confidence === 'number') {
@@ -113,7 +143,21 @@ export function annotateFalsification(findings, fileContents, opts = {}) {
113
143
  for (const f of survivors) {
114
144
  try {
115
145
  const llm = opts.llmReview(f);
116
- if (llm) f.falsification.llm = llm;
146
+ if (llm) {
147
+ f.falsification.llm = llm;
148
+ // A second, independently-identified verifier arguing the opposing
149
+ // case — this is what makes a contested finding visible as contested
150
+ // rather than resolved by whoever spoke last.
151
+ if (assertSeparation(f, VERIFIER_LLM_REVIEW).ok) {
152
+ recordVerdict(f, {
153
+ verifierId: VERIFIER_LLM_REVIEW,
154
+ lens: 'llm-review',
155
+ verdict: _verdictFor(llm.verdict),
156
+ reason: llm.reason,
157
+ });
158
+ f.verification.consensus = consensusOf(f);
159
+ }
160
+ }
117
161
  } catch { /* the LLM tier is advisory; never let it break the scan */ }
118
162
  }
119
163
  }
@@ -6,6 +6,10 @@
6
6
  // 1. The original finding's stableId no longer fires on the patched file.
7
7
  // 2. No new findings at severity ≥ medium were introduced by the patch.
8
8
  // 3. The project's existing linter (when present) passes on the patched file.
9
+ // 4. The project's own test suite (when detectable) still passes. This is
10
+ // the R5 gap-closer: a patch that silently deletes the feature would
11
+ // satisfy (1) and (2) just as well as a real fix — only running the
12
+ // tests catches that. See `test-runner.js` for detection + execution.
9
13
  //
10
14
  // If any of those fail, the caller is expected to NOT apply the patch and
11
15
  // instead surface a "fix plan" — a numbered list of steps the engineer can
@@ -16,6 +20,7 @@ import * as fs from 'node:fs';
16
20
  import * as path from 'node:path';
17
21
  import { runFullScan } from '../engine.js';
18
22
  import { gateFixOutput } from './fix-honesty-gate.js';
23
+ import { runProjectTests } from './test-runner.js';
19
24
 
20
25
  const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
21
26
 
@@ -119,27 +124,75 @@ function runLinter(cwd, cmd, args) {
119
124
  // dishonest or over-claiming fix fails the gate. When `fixMeta` is absent
120
125
  // (the deterministic MCP write path, which has no claims to check) the honesty
121
126
  // gate is skipped and behavior is unchanged.
127
+ // R5 (partial) — the test-suite stage. Runs the target project's own tests,
128
+ // in the target project's own directory, against whatever is currently on
129
+ // disk there. See `test-runner.js`'s header comment for why that run is
130
+ // deliberately NOT routed through the R1 PoC-confinement sandbox: this is
131
+ // the project's own already-trusted suite, not untrusted synthesized code.
132
+ //
133
+ // Caveat that matters for callers: `verifyPatch` above re-scans the
134
+ // candidate patch purely in memory (no write to disk), but a test runner
135
+ // needs real files — there is no cheap way to hand a runner an in-memory
136
+ // overlay. So this leg reports on the CURRENT on-disk tree, not the
137
+ // candidate `files` map, when `verifyFix` is used as a pre-write preview
138
+ // (e.g. the `verify_fix` MCP tool). Callers that apply the patch first and
139
+ // then re-verify get the strongest signal; that ordering is not enforced
140
+ // here — it's the caller's responsibility, same as it already is for the
141
+ // closed-loop `fix-verify-loop.js` path.
142
+ // Does the caller's candidate patch differ from what is on disk right now?
143
+ // If so, any test run necessarily exercised the pre-patch tree. Compared by
144
+ // content so a patch that happens to match disk (already applied) is correctly
145
+ // treated as NOT pre-patch.
146
+ function _candidateDiffersFromDisk(scanRoot, files) {
147
+ if (!files || typeof files !== 'object') return false;
148
+ for (const [rel, content] of Object.entries(files)) {
149
+ if (typeof content !== 'string') continue;
150
+ try {
151
+ const abs = path.resolve(scanRoot, rel);
152
+ if (fs.readFileSync(abs, 'utf8') !== content) return true;
153
+ } catch {
154
+ return true; // candidate file absent on disk -> definitely not applied
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+
122
160
  export async function verifyFix({
123
161
  scanRoot,
124
162
  originalFindingStableId,
125
163
  files,
126
164
  depFileContents,
127
165
  fixMeta,
166
+ testTimeoutMs,
128
167
  } = {}) {
129
168
  const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
130
169
  const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
170
+ const tests = runProjectTests(scanRoot, testTimeoutMs != null ? { timeoutMs: testTimeoutMs } : {});
171
+ // True when a candidate patch was supplied but has not been written, so the
172
+ // suite necessarily ran against the pre-patch tree. Surfaced in the summary
173
+ // and on the result so a caller cannot mistake it for a verified patch.
174
+ const _testedPrePatch = !tests.skipped && _candidateDiffersFromDisk(scanRoot, files);
175
+ const testsOk = tests.skipped ? true : tests.passed === true;
131
176
  let honesty = null;
132
177
  if (fixMeta && typeof fixMeta === 'object') {
133
178
  try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
134
179
  }
135
- const ok = rescan.ok && (lint.ok || lint.skipped) && (honesty ? honesty.ok : true);
180
+ const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && (honesty ? honesty.ok : true);
136
181
  const summary = [
137
182
  `re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
138
183
  `linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
139
184
  : lint.skipped ? `${lint.runner} not installed`
140
185
  : lint.ok ? `${lint.runner} PASS`
141
186
  : `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
187
+ // Say which tree the suite actually ran against. `files` is a candidate
188
+ // patch held in memory; the runner needs real files, so it sees whatever is
189
+ // on disk. Reporting a bare "PASS" here would let a caller believe the
190
+ // PATCH passed the tests when the suite may have run on unpatched code.
191
+ `tests: ${tests.skipped ? `skipped (${tests.reason})`
192
+ : tests.timedOut ? 'FAIL (timed out)'
193
+ : tests.passed ? `PASS${_testedPrePatch ? ' — on the CURRENT on-disk tree, NOT the candidate patch' : ''}`
194
+ : `FAIL (exit ${tests.exitCode})`}`,
142
195
  honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
143
196
  ].filter(Boolean).join('\n');
144
- return { ok, rescan, lint, honesty, summary };
197
+ return { ok, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, summary };
145
198
  }
@@ -14,7 +14,7 @@
14
14
 
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
- import * as yaml from 'js-yaml';
17
+ import * as yaml from '../util/yaml.js';
18
18
 
19
19
  const DEFAULT_POLICY = {
20
20
  allow: [],
@@ -5,7 +5,7 @@
5
5
 
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
- import * as yaml from 'js-yaml';
8
+ import * as yaml from '../util/yaml.js';
9
9
  import { statePath, safeWriteState, resolveProjectRoot } from './state-dir.js';
10
10
 
11
11
  export const PROFILES = ['vibecoder', 'pro'];
@@ -0,0 +1,33 @@
1
+ // How strongly a finding is backed by evidence.
2
+ //
3
+ // execution-proven — a proof-of-concept RAN inside the sandbox and produced
4
+ // the predicted observable effect. The strongest claim.
5
+ // proof-failed — a proof-of-concept ran and did NOT demonstrate the bug.
6
+ // A triage signal, NOT an automatic false-positive verdict:
7
+ // absence of proof is not proof of absence.
8
+ // taint-proven — the analyser's static reasoning found it; nothing executed.
9
+ // unproven — no analyser backing recorded.
10
+ export const PROOF_TIERS = Object.freeze([
11
+ 'execution-proven', 'proof-failed', 'taint-proven', 'unproven',
12
+ ]);
13
+
14
+ // Parsers that represent real analysis rather than a plain pattern match.
15
+ const _ANALYSED = new Set(['IR-TAINT', 'MULTI-SINK']);
16
+
17
+ export function proofTierOf(finding) {
18
+ if (finding?.proofTier) return finding.proofTier;
19
+ return _ANALYSED.has(finding?.parser) ? 'taint-proven' : 'unproven';
20
+ }
21
+
22
+ export function attachProofTier(finding, evidence) {
23
+ if (!PROOF_TIERS.includes(evidence?.tier)) {
24
+ throw new Error(`unknown proof tier: ${evidence?.tier}`);
25
+ }
26
+ let tier = evidence.tier;
27
+ // Guard the central honesty rule: nothing that did not RUN may be called
28
+ // execution-proven or proof-failed. Fall back to the finding's static standing.
29
+ if (!evidence.ran && (tier === 'execution-proven' || tier === 'proof-failed')) {
30
+ tier = proofTierOf({ ...finding, proofTier: undefined });
31
+ }
32
+ return { ...finding, proofTier: tier, proofEvidence: { ...evidence, tier } };
33
+ }
@@ -0,0 +1,379 @@
1
+ // R6 (threat-model-first scoping) + R9 (attack-surface-forward analysis).
2
+ //
3
+ // Every other precision mechanism in this engine kills a false positive by
4
+ // PATTERN — a sanitizer on the path, a proof that didn't reproduce, a
5
+ // confidence model fit to labelled families. This module kills one by
6
+ // RELEVANCE: a finding sitting on a path an attacker can actually reach,
7
+ // inside something the threat model says is worth attacking, matters more
8
+ // than one that is neither. It is a different axis, and it composes with
9
+ // (never replaces) the sink-driven taint engine.
10
+ //
11
+ // R9 — start at the attack surface and reason FORWARD. `entrypoint-
12
+ // inventory.js` already enumerates every attacker-reachable entry
13
+ // point (HTTP/queue/cron/CLI/env/upload/webhook). Here that inventory
14
+ // stops being a report and starts being an input: we walk the module
15
+ // import graph out from every entry-point file and record which files
16
+ // are reachable from the attack surface at all.
17
+ // R6 — `threat-model.js` already derives assets, trust boundaries and a
18
+ // STRIDE classification. Here that model re-ranks: a finding on a
19
+ // modelled asset, or one classified into a modelled STRIDE bucket,
20
+ // outranks an identical finding that is in neither.
21
+ //
22
+ // ── Recall-preserving contract (non-negotiable) ────────────────────────────
23
+ // Same precedent as `falsification.js` and `dataflow/proof-gate.js`:
24
+ // • Never removes a finding. The array in is the array out, same length,
25
+ // same order, same objects.
26
+ // • Never touches `severity`. Ever. Severity is the customer's triage
27
+ // contract; relevance is an ordinal re-rank underneath it.
28
+ // • Never asserts `unreachable` without POSITIVE evidence. "I could not
29
+ // determine it" is `entrypointReachable: null` / `relevanceTier:
30
+ // 'unknown'` — a distinct state from "I determined it is not reachable".
31
+ // A negative verdict additionally requires the intra-repo import graph to
32
+ // be provably COMPLETE: one unresolved relative import anywhere means the
33
+ // graph has a hole an attacker's path could be hiding in, and every
34
+ // would-be `unreachable` degrades to `unknown`.
35
+ // • Demotion has a floor. An unreachable finding's exploitability is scaled,
36
+ // never zeroed — a wrong reachability call must cost rank, not visibility.
37
+ //
38
+ // Fields set on each finding:
39
+ // entrypointReachable : true | false | null (null ≠ false)
40
+ // relevance : number 0..1
41
+ // relevanceTier : 'direct' | 'indirect' | 'unreachable' | 'unknown'
42
+ // relevanceFactors : string[] (human-readable, like exploitabilityFactors)
43
+
44
+ // ── Tunables ───────────────────────────────────────────────────────────────
45
+ const BASE_SCORE = {
46
+ direct: 0.75,
47
+ indirect: 0.50,
48
+ unreachable: 0.15,
49
+ unknown: 0.40,
50
+ };
51
+ // Cap so that no accumulation of R6 bonuses can push an evidenced-unreachable
52
+ // finding into the same band as a reachable one.
53
+ const UNREACHABLE_CAP = 0.30;
54
+
55
+ // Exploitability re-rank multipliers (R6's "re-rank exploitability").
56
+ const EXPLOIT_MULT = { direct: 1.15, indirect: 1.0, unreachable: 0.6, unknown: 1.0 };
57
+ const EXPLOIT_FLOOR = 0.05; // demotion never zeroes a finding out
58
+
59
+ const IMPORT_EXTS = ['', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '/index.js', '/index.ts', '/index.mjs'];
60
+ const MAX_FILES = 5000; // graph-size guard: bail to 'unknown' beyond this
61
+
62
+ // ── Source-level import extraction ─────────────────────────────────────────
63
+ // Static, literal specifiers only. Anything non-literal is recorded as a hole.
64
+ const RE_IMPORT_FROM = /\bimport\s[^;'"`]*?from\s*['"]([^'"]+)['"]/g;
65
+ const RE_IMPORT_BARE = /\bimport\s*['"]([^'"]+)['"]/g;
66
+ const RE_EXPORT_FROM = /\bexport\s[^;'"`]*?from\s*['"]([^'"]+)['"]/g;
67
+ const RE_REQUIRE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
68
+ const RE_DYN_IMPORT = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
69
+ const RE_PY_FROM = /^\s*from\s+([.\w]+)\s+import\s/gm;
70
+ const RE_PY_IMPORT = /^\s*import\s+([.\w]+)/gm;
71
+ const RE_JAVA_IMPORT = /^\s*import\s+(?:static\s+)?([\w.]+);/gm;
72
+ // Non-literal module loads: the graph cannot see through these.
73
+ const RE_DYNAMIC_HOLE = /\brequire\s*\(\s*[^'")\s]|\bimport\s*\(\s*[^'")\s]/;
74
+
75
+ function _entries(fileContents) {
76
+ if (!fileContents) return [];
77
+ if (fileContents instanceof Map) {
78
+ return [...fileContents.entries()].filter(([k, v]) => typeof k === 'string' && typeof v === 'string');
79
+ }
80
+ if (typeof fileContents === 'object') {
81
+ return Object.entries(fileContents).filter(([k, v]) => typeof k === 'string' && typeof v === 'string');
82
+ }
83
+ return [];
84
+ }
85
+
86
+ function _norm(p) {
87
+ const parts = String(p).replace(/\\/g, '/').split('/');
88
+ const out = [];
89
+ for (const seg of parts) {
90
+ if (seg === '' || seg === '.') continue;
91
+ if (seg === '..') { out.pop(); continue; }
92
+ out.push(seg);
93
+ }
94
+ return out.join('/');
95
+ }
96
+
97
+ function _dirOf(file) {
98
+ const i = String(file).replace(/\\/g, '/').lastIndexOf('/');
99
+ return i < 0 ? '' : String(file).slice(0, i);
100
+ }
101
+
102
+ // Resolve one specifier to a key in `known`. Returns the key, or null.
103
+ function _resolve(spec, fromFile, known, suffixIndex) {
104
+ if (!spec) return null;
105
+ const relative = spec.startsWith('.');
106
+ const base = relative ? _norm(`${_dirOf(fromFile)}/${spec}`) : null;
107
+
108
+ if (relative) {
109
+ for (const ext of IMPORT_EXTS) {
110
+ const cand = base + ext;
111
+ if (known.has(cand)) return cand;
112
+ }
113
+ // Also try the raw (already-extensioned) form as written.
114
+ if (known.has(base)) return base;
115
+ return null;
116
+ }
117
+
118
+ // Non-relative: python dotted module, java FQCN, or a bare package name.
119
+ const dotted = spec.replace(/\./g, '/');
120
+ for (const ext of ['.py', '.java', '.js', '.ts', '/__init__.py']) {
121
+ const hit = suffixIndex.get(dotted + ext);
122
+ if (hit) return hit;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * Build the attack-surface reachability view.
129
+ *
130
+ * @returns {{
131
+ * entryFiles: Set<string>, unauthEntryFiles: Set<string>,
132
+ * reachableFiles: Set<string>, knownFiles: Set<string>,
133
+ * graphComplete: boolean, holes: number,
134
+ * }}
135
+ */
136
+ function buildReachabilityGraph(fileContents, opts = {}) {
137
+ const knownFiles = new Set();
138
+ const entryFiles = new Set();
139
+ const unauthEntryFiles = new Set();
140
+ const reachableFiles = new Set();
141
+ let graphComplete = false;
142
+ let holes = 0;
143
+
144
+ try {
145
+ const files = _entries(fileContents);
146
+ if (files.length === 0 || files.length > MAX_FILES) {
147
+ return { entryFiles, unauthEntryFiles, reachableFiles, knownFiles, graphComplete: false, holes: 1 };
148
+ }
149
+ for (const [k] of files) knownFiles.add(k);
150
+
151
+ // Index by path suffix so a python/java module name can find its file.
152
+ const suffixIndex = new Map();
153
+ for (const k of knownFiles) {
154
+ const norm = k.replace(/\\/g, '/');
155
+ const segs = norm.split('/');
156
+ for (let i = 0; i < segs.length; i++) {
157
+ const suf = segs.slice(i).join('/');
158
+ if (!suffixIndex.has(suf)) suffixIndex.set(suf, k);
159
+ }
160
+ }
161
+
162
+ // 1) Entry-point files, from the inventory and/or the raw route list.
163
+ const inv = opts.entrypointInventory;
164
+ const invEntries = inv && Array.isArray(inv.entrypoints) ? inv.entrypoints
165
+ : Array.isArray(opts.entrypoints) ? opts.entrypoints : [];
166
+ for (const e of invEntries) {
167
+ if (!e || typeof e.file !== 'string' || !e.file) continue;
168
+ entryFiles.add(e.file);
169
+ if (e.trust !== 'authenticated') unauthEntryFiles.add(e.file);
170
+ }
171
+ for (const r of (Array.isArray(opts.routes) ? opts.routes : [])) {
172
+ if (!r || typeof r.file !== 'string' || !r.file) continue;
173
+ entryFiles.add(r.file);
174
+ if (r.hasAuth !== true) unauthEntryFiles.add(r.file);
175
+ }
176
+
177
+ // 2) Import edges. A hole is an edge the graph cannot see: an unresolved
178
+ // intra-repo (relative) specifier, or a non-literal module load. Holes
179
+ // are recorded PER FILE because only holes in files that turn out to be
180
+ // REACHABLE can hide a path into somewhere we'd otherwise call
181
+ // unreachable — a hidden edge always originates in the importing file,
182
+ // so a hole inside an already-unreachable file cannot make anything
183
+ // reachable. Anything else would make a negative verdict impossible in
184
+ // any real repository (one `require(varName)` anywhere would veto all).
185
+ const edges = new Map();
186
+ const holeFiles = new Set();
187
+ for (const [file, src] of files) {
188
+ const out = new Set();
189
+ if (RE_DYNAMIC_HOLE.test(src)) { holes++; holeFiles.add(file); }
190
+ for (const re of [RE_IMPORT_FROM, RE_EXPORT_FROM, RE_IMPORT_BARE, RE_REQUIRE, RE_DYN_IMPORT,
191
+ RE_PY_FROM, RE_PY_IMPORT, RE_JAVA_IMPORT]) {
192
+ re.lastIndex = 0;
193
+ let m;
194
+ while ((m = re.exec(src))) {
195
+ const spec = m[1];
196
+ if (!spec) continue;
197
+ const target = _resolve(spec, file, knownFiles, suffixIndex);
198
+ if (target && target !== file) out.add(target);
199
+ // Only an unresolved INTRA-repo (relative) specifier is a hole:
200
+ // a bare package name points at a third-party module that was
201
+ // never part of `fileContents` in the first place.
202
+ else if (!target && spec.startsWith('.')) { holes++; holeFiles.add(file); }
203
+ }
204
+ }
205
+ edges.set(file, out);
206
+ }
207
+
208
+ // 3) Forward BFS from the attack surface.
209
+ const queue = [...entryFiles].filter(f => knownFiles.has(f));
210
+ for (const f of queue) reachableFiles.add(f);
211
+ while (queue.length) {
212
+ const cur = queue.shift();
213
+ for (const next of (edges.get(cur) || [])) {
214
+ if (reachableFiles.has(next)) continue;
215
+ reachableFiles.add(next);
216
+ queue.push(next);
217
+ }
218
+ }
219
+
220
+ // A negative verdict is admissible only when we actually found an attack
221
+ // surface AND no file reachable from it has an invisible edge.
222
+ let reachableHole = false;
223
+ for (const f of reachableFiles) if (holeFiles.has(f)) { reachableHole = true; break; }
224
+ graphComplete = !reachableHole && entryFiles.size > 0;
225
+ } catch (_) {
226
+ return { entryFiles, unauthEntryFiles, reachableFiles, knownFiles, graphComplete: false, holes: holes || 1 };
227
+ }
228
+ return { entryFiles, unauthEntryFiles, reachableFiles, knownFiles, graphComplete, holes };
229
+ }
230
+
231
+ // ── R6: threat-model bonuses ───────────────────────────────────────────────
232
+ function _threatBonus(f, threatModel, factors) {
233
+ let bonus = 0;
234
+ if (!threatModel || typeof threatModel !== 'object') return bonus;
235
+
236
+ const assets = Array.isArray(threatModel.assets) ? threatModel.assets : [];
237
+ for (const a of assets) {
238
+ if (!a || a.file !== f.file) continue;
239
+ const exposed = a.exposure === 'public-api' || a.exposure === 'external-api';
240
+ bonus += exposed ? 0.10 : 0.07;
241
+ factors.push(`modelled asset in file: ${a.category || a.name || 'asset'}${exposed ? ` (${a.exposure})` : ''}`);
242
+ break;
243
+ }
244
+
245
+ const boundaries = Array.isArray(threatModel.trustBoundaries) ? threatModel.trustBoundaries : [];
246
+ if (boundaries.some(b => b && b.file === f.file)) {
247
+ bonus += 0.05;
248
+ factors.push('trust boundary crossed in this file');
249
+ }
250
+
251
+ const stride = threatModel.stride && typeof threatModel.stride === 'object' ? threatModel.stride : null;
252
+ if (stride) {
253
+ for (const [cat, items] of Object.entries(stride)) {
254
+ if (!Array.isArray(items)) continue;
255
+ if (items.some(it => it && it.file === f.file && (it.line === f.line || it.vuln === f.vuln))) {
256
+ bonus += 0.08;
257
+ factors.push(`modelled STRIDE threat: ${cat}`);
258
+ break;
259
+ }
260
+ }
261
+ }
262
+ return bonus;
263
+ }
264
+
265
+ /**
266
+ * Pure scorer. Does NOT mutate the finding.
267
+ *
268
+ * @param graph the object returned by buildReachabilityGraph (or a subset
269
+ * with entryFiles / reachableFiles / knownFiles / graphComplete).
270
+ * @returns {{ tier, score, reachable, factors }}
271
+ */
272
+ function scoreRelevance(f, graph, threatModel) {
273
+ const factors = [];
274
+ const g = graph || {};
275
+ const entryFiles = g.entryFiles instanceof Set ? g.entryFiles : new Set();
276
+ const reachableFiles = g.reachableFiles instanceof Set ? g.reachableFiles : new Set();
277
+ const knownFiles = g.knownFiles instanceof Set ? g.knownFiles : new Set();
278
+ const unauthEntryFiles = g.unauthEntryFiles instanceof Set ? g.unauthEntryFiles : new Set();
279
+ const file = f && typeof f.file === 'string' ? f.file : null;
280
+
281
+ let tier;
282
+ let reachable;
283
+ if (!file || entryFiles.size === 0) {
284
+ tier = 'unknown';
285
+ reachable = null;
286
+ factors.push(entryFiles.size === 0
287
+ ? 'no attack surface enumerated — reachability not determinable'
288
+ : 'finding has no file — reachability not determinable');
289
+ } else if (entryFiles.has(file)) {
290
+ tier = 'direct';
291
+ reachable = true;
292
+ factors.push('finding sits in an entry-point file (direct attack surface)');
293
+ if (unauthEntryFiles.has(file)) factors.push('entry point is unauthenticated');
294
+ } else if (reachableFiles.has(file)) {
295
+ tier = 'indirect';
296
+ reachable = true;
297
+ factors.push('reachable from an entry point via the module import graph');
298
+ } else if (g.graphComplete === true && knownFiles.has(file)) {
299
+ tier = 'unreachable';
300
+ reachable = false;
301
+ factors.push('no import path from any enumerated entry point (import graph complete)');
302
+ } else {
303
+ tier = 'unknown';
304
+ reachable = null;
305
+ factors.push(knownFiles.has(file)
306
+ ? 'import graph incomplete — no reachability verdict admissible'
307
+ : 'file not present in the scanned set — reachability not determinable');
308
+ }
309
+
310
+ let score = BASE_SCORE[tier];
311
+ score += _threatBonus(f || {}, threatModel, factors);
312
+ if (tier === 'direct' && unauthEntryFiles.has(file)) score += 0.10;
313
+ if (tier === 'unreachable') score = Math.min(score, UNREACHABLE_CAP);
314
+ score = Math.max(0, Math.min(1, score));
315
+
316
+ return { tier, score: Math.round(score * 1000) / 1000, reachable, factors };
317
+ }
318
+
319
+ /**
320
+ * Default-on annotator. Recall-preserving: never removes a finding, never
321
+ * touches severity, never asserts 'unreachable' without positive evidence.
322
+ *
323
+ * @param ctx.fileContents Map|object of scanned sources
324
+ * @param ctx.entrypointInventory output of buildEntrypointInventory()
325
+ * @param ctx.routes route list (fallback attack surface)
326
+ * @param ctx.threatModel output of buildThreatModel()
327
+ */
328
+ export function annotateRelevance(findings, ctx = {}) {
329
+ if (!Array.isArray(findings)) return findings;
330
+ let graph;
331
+ try {
332
+ const c = ctx && typeof ctx === 'object' ? ctx : {};
333
+ graph = buildReachabilityGraph(c.fileContents, {
334
+ entrypointInventory: c.entrypointInventory,
335
+ entrypoints: c.entrypoints,
336
+ routes: c.routes,
337
+ });
338
+ } catch (_) {
339
+ graph = { entryFiles: new Set(), unauthEntryFiles: new Set(), reachableFiles: new Set(), knownFiles: new Set(), graphComplete: false, holes: 1 };
340
+ }
341
+ const threatModel = ctx && typeof ctx === 'object' ? ctx.threatModel : null;
342
+
343
+ for (const f of findings) {
344
+ if (!f || typeof f !== 'object') continue;
345
+ try {
346
+ const r = scoreRelevance(f, graph, threatModel);
347
+ f.entrypointReachable = r.reachable;
348
+ f.relevance = r.score;
349
+ f.relevanceTier = r.tier;
350
+ f.relevanceFactors = r.factors;
351
+
352
+ // R6 re-rank: exploitability is an ordinal priority, so scaling it by
353
+ // relevance is exactly the intended re-ranking. Severity is untouched,
354
+ // and demotion has a floor — a wrong call costs rank, not visibility.
355
+ const mult = EXPLOIT_MULT[r.tier];
356
+ if (typeof f.exploitability === 'number' && Number.isFinite(f.exploitability) && mult !== 1) {
357
+ const adjusted = Math.max(EXPLOIT_FLOOR, Math.min(1, f.exploitability * mult));
358
+ f.exploitability = Math.round(adjusted * 100) / 100;
359
+ if (typeof f.priorityScore === 'number') f.priorityScore = f.exploitability;
360
+ if (Array.isArray(f.exploitabilityFactors)) f.exploitabilityFactors.push(`relevance:${r.tier}`);
361
+ // Keep the tier label consistent with the re-ranked score. Same
362
+ // thresholds as annotateExploitability; severity is NOT derived here.
363
+ if (f.exploitability >= 0.80) f.exploitabilityTier = 'critical';
364
+ else if (f.exploitability >= 0.60) f.exploitabilityTier = 'high';
365
+ else if (f.exploitability >= 0.35) f.exploitabilityTier = 'medium';
366
+ else f.exploitabilityTier = 'low';
367
+ }
368
+ } catch (_) {
369
+ f.entrypointReachable = null;
370
+ f.relevance = BASE_SCORE.unknown;
371
+ f.relevanceTier = 'unknown';
372
+ f.relevanceFactors = ['relevance scoring failed — no verdict'];
373
+ }
374
+ }
375
+ return findings;
376
+ }
377
+
378
+ // Test-only surface (underscore-prefixed: not part of the public API).
379
+ export const _internals = { buildReachabilityGraph, scoreRelevance, BASE_SCORE, EXPLOIT_MULT };
@@ -9,7 +9,7 @@
9
9
 
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
- import * as yaml from 'js-yaml';
12
+ import * as yaml from '../util/yaml.js';
13
13
  import { verifyLastScan } from './integrity.js';
14
14
  import { statePath } from './state-dir.js';
15
15
 
@@ -38,7 +38,7 @@
38
38
 
39
39
  import * as fs from 'node:fs';
40
40
  import * as path from 'node:path';
41
- import * as yaml from 'js-yaml';
41
+ import * as yaml from '../util/yaml.js';
42
42
 
43
43
  const DEFAULT_POLICY = {
44
44
  acceptRisk: [],