@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.
- package/CHANGELOG.md +101 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +2 -2
- package/dist/113.index.js +209 -7
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -162
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +22 -14
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +154 -7
- package/src/integrations/index.js +1 -1
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/posture/CLAUDE.md +115 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +45 -1
- package/src/posture/fix-verify.js +55 -2
- package/src/posture/license-policy.js +1 -1
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- 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)
|
|
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
|
}
|
package/src/posture/profile.js
CHANGED
|
@@ -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
|
|
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 };
|