@clear-capabilities/agentic-security-scanner 0.127.0 → 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 (88) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +727 -0
  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 +19 -8
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/826.index.js +4 -1
  13. package/dist/830.index.js +1 -1
  14. package/dist/agentic-security.mjs +113 -163
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/package.json +23 -15
  17. package/src/dataflow/CLAUDE.md +4 -1
  18. package/src/dataflow/async-sequencing.js +8 -3
  19. package/src/dataflow/catalog.js +278 -11
  20. package/src/dataflow/cross-repo.js +1 -1
  21. package/src/dataflow/cross-service-taint.js +1 -1
  22. package/src/dataflow/engine.js +182 -61
  23. package/src/dataflow/ifds.js +10 -5
  24. package/src/dataflow/index.js +15 -3
  25. package/src/dataflow/points-to.js +8 -2
  26. package/src/dataflow/proof-gate.js +7 -0
  27. package/src/dataflow/sanitizer-gate.js +89 -0
  28. package/src/dataflow/tabulation.js +14 -3
  29. package/src/engine.js +181 -8
  30. package/src/integrations/index.js +1 -1
  31. package/src/integrations/tickets.js +9 -3
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +5 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +17 -6
  49. package/src/posture/CLAUDE.md +122 -0
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +199 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/cross-lang-openapi.js +1 -1
  56. package/src/posture/custom-rules.js +1 -1
  57. package/src/posture/entrypoint-inventory.js +248 -0
  58. package/src/posture/execution-proof.js +52 -0
  59. package/src/posture/exploitability-probability.js +1 -1
  60. package/src/posture/falsification.js +165 -0
  61. package/src/posture/fix-honesty-gate.js +175 -0
  62. package/src/posture/fix-verify.js +71 -3
  63. package/src/posture/license-policy.js +1 -1
  64. package/src/posture/model-routing.js +126 -0
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +262 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/pr-comment.js +3 -1
  76. package/src/report/index.js +11 -0
  77. package/src/runScan.js +3 -1
  78. package/src/sandbox/CLAUDE.md +218 -0
  79. package/src/sandbox/backend-disabled.js +14 -0
  80. package/src/sandbox/backend-namespace.js +83 -0
  81. package/src/sandbox/backend-userspace.js +100 -0
  82. package/src/sandbox/capabilities.js +53 -0
  83. package/src/sandbox/index.js +30 -0
  84. package/src/sandbox/limits.js +42 -0
  85. package/src/sandbox/result.js +104 -0
  86. package/src/sca/dep-confusion.js +1 -1
  87. package/src/util/untrusted.js +148 -0
  88. package/src/util/yaml.js +24 -0
@@ -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
@@ -15,6 +19,8 @@ import { spawnSync } from 'node:child_process';
15
19
  import * as fs from 'node:fs';
16
20
  import * as path from 'node:path';
17
21
  import { runFullScan } from '../engine.js';
22
+ import { gateFixOutput } from './fix-honesty-gate.js';
23
+ import { runProjectTests } from './test-runner.js';
18
24
 
19
25
  const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
20
26
 
@@ -110,21 +116,83 @@ function runLinter(cwd, cmd, args) {
110
116
 
111
117
  // Top-level verify: re-scan + lint. Returns the combined verdict + a
112
118
  // human-readable summary string suitable for surfacing to the user.
119
+ // Addition #7 — deterministic honesty gates on fix output. When the caller
120
+ // supplies `fixMeta` ({ residual, verdict, evidence, signals }) — e.g. the
121
+ // security-fixer agent's residual-risk text + completeness signals — the fix's
122
+ // claims are checked mechanically (no hand-wave residual prose, a cited
123
+ // file:line for any FP/safe verdict, and a FULL/MITIGATION/WORKAROUND tier). A
124
+ // dishonest or over-claiming fix fails the gate. When `fixMeta` is absent
125
+ // (the deterministic MCP write path, which has no claims to check) the honesty
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
+
113
160
  export async function verifyFix({
114
161
  scanRoot,
115
162
  originalFindingStableId,
116
163
  files,
117
164
  depFileContents,
165
+ fixMeta,
166
+ testTimeoutMs,
118
167
  } = {}) {
119
168
  const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
120
169
  const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
121
- const ok = rescan.ok && (lint.ok || lint.skipped);
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;
176
+ let honesty = null;
177
+ if (fixMeta && typeof fixMeta === 'object') {
178
+ try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
179
+ }
180
+ const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && (honesty ? honesty.ok : true);
122
181
  const summary = [
123
182
  `re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
124
183
  `linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
125
184
  : lint.skipped ? `${lint.runner} not installed`
126
185
  : lint.ok ? `${lint.runner} PASS`
127
186
  : `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
128
- ].join('\n');
129
- return { ok, rescan, lint, summary };
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})`}`,
195
+ honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
196
+ ].filter(Boolean).join('\n');
197
+ return { ok, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, summary };
130
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: [],
@@ -0,0 +1,126 @@
1
+ // Capability-based model routing for cost-sensitive subagent dispatch.
2
+ //
3
+ // A declarative CWE/severity → model policy. When the orchestrator is about to
4
+ // dispatch a delegable, cost-sensitive subagent for a finding (fixer, triager,
5
+ // PoC generator, attack-chain synthesizer), it can ask this module which model
6
+ // tier the work actually warrants — spend Opus reasoning on the hard classes
7
+ // (crypto, auth, deserialization, XXE, cross-file taint) and let Haiku handle
8
+ // the mechanical hardening findings.
9
+ //
10
+ // Mirrors the model IDs + tier vocabulary of hooks/model-cost-advisor.js:
11
+ // strongest = claude-opus-4-8 (high effort)
12
+ // mid = claude-sonnet-4-6 (medium effort)
13
+ // cheapest = claude-haiku-4-5 (low effort)
14
+ //
15
+ // This is a *preference*, not a ceiling — a caller may always upgrade when the
16
+ // specific task clearly needs more capability (same spirit as the
17
+ // subagentOverride contract documented in the root CLAUDE.md).
18
+ //
19
+ // Pure + deterministic — no I/O, no network, never throws.
20
+
21
+ // Model IDs (module-local; the public API is the routing functions below).
22
+ const MODEL_STRONGEST = 'claude-opus-4-8';
23
+ const MODEL_MID = 'claude-sonnet-4-6';
24
+ const MODEL_CHEAPEST = 'claude-haiku-4-5';
25
+
26
+ const LABEL = {
27
+ [MODEL_STRONGEST]: 'Opus 4.8',
28
+ [MODEL_MID]: 'Sonnet 4.6',
29
+ [MODEL_CHEAPEST]: 'Haiku 4.5',
30
+ };
31
+
32
+ // Hard classes — subtle, high-blast-radius bugs where a wrong fix is worse than
33
+ // no fix: auth, TLS/cert validation, weak crypto/hashing/randomness, signature
34
+ // verification, unsafe deserialization, XXE. Worth Opus when they land at high
35
+ // or critical severity.
36
+ const HARD_CWES = new Set([
37
+ 'CWE-287', // improper authentication
38
+ 'CWE-295', // improper certificate validation
39
+ 'CWE-327', // broken / risky crypto algorithm
40
+ 'CWE-328', // weak hash
41
+ 'CWE-330', // use of insufficiently random values
42
+ 'CWE-347', // improper verification of cryptographic signature
43
+ 'CWE-502', // deserialization of untrusted data
44
+ 'CWE-611', // XML external entity (XXE)
45
+ ]);
46
+
47
+ // Mid classes — the common injection / traversal / CSRF / SSRF families.
48
+ // Well-understood remediations; Sonnet handles them cost-effectively.
49
+ const MID_CWES = new Set([
50
+ 'CWE-22', // path traversal
51
+ 'CWE-78', // OS command injection
52
+ 'CWE-79', // cross-site scripting
53
+ 'CWE-89', // SQL injection
54
+ 'CWE-94', // code injection
55
+ 'CWE-352', // cross-site request forgery
56
+ 'CWE-434', // unrestricted file upload
57
+ 'CWE-601', // open redirect
58
+ 'CWE-918', // server-side request forgery
59
+ ]);
60
+
61
+ // Extract the canonical `CWE-<n>` token from a finding.cwe value that may be a
62
+ // bare id ("CWE-89") or a descriptive string ("CWE-89: SQL Injection").
63
+ // Returns the uppercased id, or null when nothing parseable is present.
64
+ export function parseCwe(raw) {
65
+ if (typeof raw !== 'string') return null;
66
+ const m = raw.match(/CWE-\d+/i);
67
+ return m ? m[0].toUpperCase() : null;
68
+ }
69
+
70
+ // Route a single finding to a model tier. First match wins.
71
+ // Returns { model, effort, reason }.
72
+ export function routeModelForFinding(finding) {
73
+ const f = finding || {};
74
+ const severity = typeof f.severity === 'string' ? f.severity.toLowerCase() : '';
75
+ const cwe = parseCwe(f.cwe);
76
+ const multiFile = f.multiFile === true || f.isCrossFile === true;
77
+ const highOrCritical = severity === 'high' || severity === 'critical';
78
+
79
+ // ── Tier 1: strongest (Opus, high effort) ──
80
+ if (severity === 'critical') {
81
+ return { model: MODEL_STRONGEST, effort: 'high',
82
+ reason: `Critical severity — worth ${LABEL[MODEL_STRONGEST]} at high effort.` };
83
+ }
84
+ if (cwe && HARD_CWES.has(cwe) && highOrCritical) {
85
+ return { model: MODEL_STRONGEST, effort: 'high',
86
+ reason: `${cwe} at ${severity} severity is a hard class (crypto / auth / deserialization / XXE) — ${LABEL[MODEL_STRONGEST]} at high effort.` };
87
+ }
88
+ if (multiFile) {
89
+ return { model: MODEL_STRONGEST, effort: 'high',
90
+ reason: `Cross-file finding — needs ${LABEL[MODEL_STRONGEST]} at high effort to reason across files.` };
91
+ }
92
+
93
+ // ── Tier 2: mid (Sonnet, medium effort) ──
94
+ if (cwe && MID_CWES.has(cwe)) {
95
+ return { model: MODEL_MID, effort: 'medium',
96
+ reason: `${cwe} is a common injection / traversal class — ${LABEL[MODEL_MID]} at medium effort.` };
97
+ }
98
+ if (severity === 'high') {
99
+ return { model: MODEL_MID, effort: 'medium',
100
+ reason: `High severity — ${LABEL[MODEL_MID]} at medium effort.` };
101
+ }
102
+
103
+ // ── Tier 3: cheapest (Haiku, low effort) ──
104
+ return { model: MODEL_CHEAPEST, effort: 'low',
105
+ reason: `${cwe ? `${cwe} at ` : ''}${severity || 'low'} severity is a simple / hardening class — ${LABEL[MODEL_CHEAPEST]} at low effort.` };
106
+ }
107
+
108
+ // Route a list of findings. Returns [{ finding, model, effort, reason }, …].
109
+ export function routeModelForFindings(findings) {
110
+ const list = Array.isArray(findings) ? findings : [];
111
+ return list.map((finding) => ({ finding, ...routeModelForFinding(finding) }));
112
+ }
113
+
114
+ // Tally how many findings land on each model tier.
115
+ // Returns { 'claude-opus-4-8': n, 'claude-sonnet-4-6': n, 'claude-haiku-4-5': n }.
116
+ export function summarizeRouting(findings) {
117
+ const counts = {
118
+ [MODEL_STRONGEST]: 0,
119
+ [MODEL_MID]: 0,
120
+ [MODEL_CHEAPEST]: 0,
121
+ };
122
+ for (const { model } of routeModelForFindings(findings)) {
123
+ if (model in counts) counts[model] += 1;
124
+ }
125
+ return counts;
126
+ }
@@ -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 };