@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.
- package/CHANGELOG.md +161 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +727 -0
- 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 +19 -8
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -163
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +23 -15
- 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 +181 -8
- package/src/integrations/index.js +1 -1
- package/src/integrations/tickets.js +9 -3
- 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/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +122 -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/entrypoint-inventory.js +248 -0
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +165 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +71 -3
- package/src/posture/license-policy.js +1 -1
- package/src/posture/model-routing.js +126 -0
- 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/root-cause-sweep.js +262 -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/pr-comment.js +3 -1
- 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/untrusted.js +148 -0
- 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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|
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 };
|