@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
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Attack-surface completeness inventory (addition #2).
|
|
2
|
+
//
|
|
3
|
+
// Enumerates every attacker-reachable entry point across a codebase and
|
|
4
|
+
// assigns each a disposition, producing an auditable coverage ledger. The
|
|
5
|
+
// point is completeness: rather than only reporting where a finding fired,
|
|
6
|
+
// this lists every surface an attacker can *reach* (HTTP routes, queue
|
|
7
|
+
// consumers, cron jobs, CLI arg parsing, environment reads, file uploads,
|
|
8
|
+
// webhooks) and states, for each, whether it was traced clean, has an open
|
|
9
|
+
// finding, or otherwise. A reviewer can then audit the coverage rather than
|
|
10
|
+
// trust that "no finding == safe."
|
|
11
|
+
//
|
|
12
|
+
// Entry-point types (7):
|
|
13
|
+
// http — inbound HTTP routes (from opts.routes; one entry per route)
|
|
14
|
+
// queue — message-queue consumers (Kafka / SQS / RabbitMQ / pub-sub)
|
|
15
|
+
// cron — scheduled jobs (@Scheduled, cron.schedule, setInterval, celery)
|
|
16
|
+
// cli — command-line argument parsing (argv / argparse / commander / …)
|
|
17
|
+
// env — environment-variable reads (process.env / getenv)
|
|
18
|
+
// upload — file-upload sinks (multer / req.files / MultipartFile)
|
|
19
|
+
// webhook — inbound webhook handlers (route path or /webhook literal)
|
|
20
|
+
//
|
|
21
|
+
// Granularity: HTTP/webhook routes are enumerated per route. The regex-
|
|
22
|
+
// discovered surfaces are enumerated per (type, file) — a source file that
|
|
23
|
+
// exposes a surface is counted once for that surface, recording the first
|
|
24
|
+
// matching line. This avoids double-counting an import line plus its use
|
|
25
|
+
// site (e.g. `import multer` + `multer(...)`) while still distinguishing
|
|
26
|
+
// distinct surface types that share a file.
|
|
27
|
+
//
|
|
28
|
+
// No throwing: every public entry degrades to an empty/zeroed ledger.
|
|
29
|
+
|
|
30
|
+
// ── Entry-point type order (fixed — drives byType key order) ────────────────
|
|
31
|
+
const ENTRY_TYPES = ['http', 'queue', 'cron', 'cli', 'env', 'upload', 'webhook'];
|
|
32
|
+
|
|
33
|
+
// ── Regex-discovered surface patterns ──────────────────────────────────────
|
|
34
|
+
// Non-global regexes (no /g) so repeated .test()/.exec() calls are stateless.
|
|
35
|
+
// Ordered roughly most-specific → most-generic within each type.
|
|
36
|
+
const SURFACE_PATTERNS = {
|
|
37
|
+
queue: [
|
|
38
|
+
/@KafkaListener\b/,
|
|
39
|
+
/@SqsListener\b/,
|
|
40
|
+
/@RabbitListener\b/,
|
|
41
|
+
/\b(?:sqs|kafka|rabbit)\w*\s*\.\s*(?:consume|subscribe|receiveMessage|poll|on)\b/i,
|
|
42
|
+
/new\s+(?:Kafka)?Consumer\s*\(/,
|
|
43
|
+
/\.consume\s*\(/,
|
|
44
|
+
/\.subscribe\s*\(/,
|
|
45
|
+
],
|
|
46
|
+
cron: [
|
|
47
|
+
/@Scheduled\b/,
|
|
48
|
+
/cron\.schedule\s*\(/,
|
|
49
|
+
/\bnode-cron\b/,
|
|
50
|
+
/@shared_task\b/,
|
|
51
|
+
/@periodic_task\b/,
|
|
52
|
+
/@(?:app\.)?task\b/, // celery
|
|
53
|
+
/setInterval\s*\(/, // interval used as a recurring job
|
|
54
|
+
],
|
|
55
|
+
cli: [
|
|
56
|
+
/process\.argv\b/,
|
|
57
|
+
/\bargparse\b/,
|
|
58
|
+
/\bcommander\b/,
|
|
59
|
+
/\byargs\b/,
|
|
60
|
+
/\bcobra\.Command\b/,
|
|
61
|
+
/\bflag\.Parse\s*\(/,
|
|
62
|
+
],
|
|
63
|
+
env: [
|
|
64
|
+
/process\.env\.\w+/,
|
|
65
|
+
/os\.getenv\s*\(/,
|
|
66
|
+
/System\.getenv\s*\(/,
|
|
67
|
+
],
|
|
68
|
+
upload: [
|
|
69
|
+
/\bmulter\b/,
|
|
70
|
+
/\breq\.files?\b/,
|
|
71
|
+
/\brequest\.files?\b/,
|
|
72
|
+
/multipart\/form-data/i,
|
|
73
|
+
/\bMultipartFile\b/,
|
|
74
|
+
],
|
|
75
|
+
webhook: [
|
|
76
|
+
/['"`][^'"`]*\/webhook[^'"`]*['"`]/i, // a "/webhook…" string literal
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Auth tokens — presence near an entry point in the same file promotes its
|
|
81
|
+
// trust boundary from unauthenticated → authenticated. `\bauth\b` matches the
|
|
82
|
+
// bare token only (not "author" / "oauth", which retain their word chars).
|
|
83
|
+
const AUTH_TOKEN = /(?:@PreAuthorize|requireAuth|require_auth|login_required|isAuthenticated|authenticate\w*|authorize\w*|authMiddleware|authGuard|\bauth\b)/i;
|
|
84
|
+
|
|
85
|
+
const AUTH_WINDOW = 8; // lines above/below the entry point to scan for auth
|
|
86
|
+
|
|
87
|
+
// ── Input normalization ─────────────────────────────────────────────────────
|
|
88
|
+
// Accept either a Map<filepath,string> or a plain object {path: source}.
|
|
89
|
+
function _entries(fileContents) {
|
|
90
|
+
if (!fileContents) return [];
|
|
91
|
+
if (fileContents instanceof Map) {
|
|
92
|
+
return [...fileContents.entries()].filter(([, v]) => typeof v === 'string');
|
|
93
|
+
}
|
|
94
|
+
if (typeof fileContents === 'object') {
|
|
95
|
+
return Object.entries(fileContents).filter(([, v]) => typeof v === 'string');
|
|
96
|
+
}
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _emptyCoverage() {
|
|
101
|
+
const byType = {};
|
|
102
|
+
for (const t of ENTRY_TYPES) byType[t] = 0;
|
|
103
|
+
return { total: 0, byType, tracedSafe: 0, finding: 0, notReachable: 0, noInput: 0 };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function _authNear(lines, line) {
|
|
107
|
+
const lo = Math.max(0, line - 1 - AUTH_WINDOW);
|
|
108
|
+
const hi = Math.min(lines.length, line + AUTH_WINDOW);
|
|
109
|
+
return AUTH_TOKEN.test(lines.slice(lo, hi).join('\n'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _routeHasInput(route) {
|
|
113
|
+
if (Array.isArray(route.params) && route.params.length) return true;
|
|
114
|
+
const p = typeof route.path === 'string' ? route.path : '';
|
|
115
|
+
// :id (Express/Koa), {id} (FastAPI/Spring), <id> (Flask/Django) → has input.
|
|
116
|
+
return /[:{<]\w/.test(p);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _clip(s, n = 80) {
|
|
120
|
+
const str = String(s == null ? '' : s).trim();
|
|
121
|
+
return str.length > n ? str.slice(0, n) : str;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Core ─────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export function buildEntrypointInventory(fileContents, opts = {}) {
|
|
127
|
+
const coverage = _emptyCoverage();
|
|
128
|
+
const entrypoints = [];
|
|
129
|
+
try {
|
|
130
|
+
const routes = Array.isArray(opts && opts.routes) ? opts.routes : [];
|
|
131
|
+
const findings = Array.isArray(opts && opts.findings) ? opts.findings : [];
|
|
132
|
+
const findingFiles = new Set(
|
|
133
|
+
findings.map(f => (f && typeof f.file === 'string' ? f.file : null)).filter(Boolean),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// 1) HTTP / webhook entry points — one per route.
|
|
137
|
+
for (const route of routes) {
|
|
138
|
+
if (!route || typeof route !== 'object') continue;
|
|
139
|
+
const file = typeof route.file === 'string' && route.file ? route.file : '(unknown)';
|
|
140
|
+
const line = Number.isInteger(route.line) ? route.line : 0;
|
|
141
|
+
const method = typeof route.method === 'string' ? route.method : 'GET';
|
|
142
|
+
const path = typeof route.path === 'string' ? route.path : '';
|
|
143
|
+
const isWebhook = /webhook/i.test(path) || /webhook/i.test(String(route.handler || ''));
|
|
144
|
+
const type = isWebhook ? 'webhook' : 'http';
|
|
145
|
+
const name = _clip(route.handler || `${method} ${path}`.trim() || type);
|
|
146
|
+
|
|
147
|
+
// Trust: an authenticated route (hasAuth) or a nearby auth token wins.
|
|
148
|
+
let trust = route.hasAuth === true ? 'authenticated' : 'unauthenticated';
|
|
149
|
+
if (trust === 'unauthenticated' && fileContents) {
|
|
150
|
+
const src = _srcOf(fileContents, file);
|
|
151
|
+
if (src && _authNear(src.split(/\r?\n/), line)) trust = 'authenticated';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let disposition;
|
|
155
|
+
if (findingFiles.has(file)) disposition = 'finding';
|
|
156
|
+
else if (type === 'http' && !_routeHasInput(route)) disposition = 'no-input';
|
|
157
|
+
else disposition = 'traced-safe';
|
|
158
|
+
|
|
159
|
+
entrypoints.push({ type, file, line, name, trust, disposition });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 2) Regex-discovered surfaces — one per (type, file), first matching line.
|
|
163
|
+
for (const [file, source] of _entries(fileContents)) {
|
|
164
|
+
const lines = source.split(/\r?\n/);
|
|
165
|
+
for (const type of ENTRY_TYPES) {
|
|
166
|
+
const pats = SURFACE_PATTERNS[type];
|
|
167
|
+
if (!pats) continue; // http has no file-scan pattern (routes-only)
|
|
168
|
+
let hitLine = -1;
|
|
169
|
+
let hitText = '';
|
|
170
|
+
outer:
|
|
171
|
+
for (let i = 0; i < lines.length; i++) {
|
|
172
|
+
for (const re of pats) {
|
|
173
|
+
const m = re.exec(lines[i]);
|
|
174
|
+
if (m) { hitLine = i + 1; hitText = _clip(m[0] || type); break outer; }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (hitLine < 0) continue;
|
|
178
|
+
|
|
179
|
+
const trust = _authNear(lines, hitLine) ? 'authenticated' : 'unauthenticated';
|
|
180
|
+
// Regex surfaces inherently carry attacker-controlled input (a queue
|
|
181
|
+
// message, an env value, a CLI arg, an upload). Only HTTP routes get
|
|
182
|
+
// the optional 'no-input' disposition.
|
|
183
|
+
const disposition = findingFiles.has(file) ? 'finding' : 'traced-safe';
|
|
184
|
+
entrypoints.push({ type, file, line: hitLine, name: hitText || type, trust, disposition });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 3) Roll up the coverage ledger.
|
|
189
|
+
coverage.total = entrypoints.length;
|
|
190
|
+
for (const e of entrypoints) {
|
|
191
|
+
if (coverage.byType[e.type] != null) coverage.byType[e.type]++;
|
|
192
|
+
if (e.disposition === 'finding') coverage.finding++;
|
|
193
|
+
else if (e.disposition === 'not-reachable') coverage.notReachable++;
|
|
194
|
+
else if (e.disposition === 'no-input') coverage.noInput++;
|
|
195
|
+
else coverage.tracedSafe++;
|
|
196
|
+
}
|
|
197
|
+
} catch (_) {
|
|
198
|
+
// Degrade to whatever we accumulated before the error (or empty).
|
|
199
|
+
return { entrypoints, coverage: _recount(entrypoints) };
|
|
200
|
+
}
|
|
201
|
+
return { entrypoints, coverage };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Defensive re-count used only on the error path.
|
|
205
|
+
function _recount(entrypoints) {
|
|
206
|
+
const coverage = _emptyCoverage();
|
|
207
|
+
coverage.total = entrypoints.length;
|
|
208
|
+
for (const e of entrypoints) {
|
|
209
|
+
if (coverage.byType[e.type] != null) coverage.byType[e.type]++;
|
|
210
|
+
if (e.disposition === 'finding') coverage.finding++;
|
|
211
|
+
else if (e.disposition === 'not-reachable') coverage.notReachable++;
|
|
212
|
+
else if (e.disposition === 'no-input') coverage.noInput++;
|
|
213
|
+
else coverage.tracedSafe++;
|
|
214
|
+
}
|
|
215
|
+
return coverage;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function _srcOf(fileContents, file) {
|
|
219
|
+
if (!fileContents) return null;
|
|
220
|
+
if (fileContents instanceof Map) {
|
|
221
|
+
const v = fileContents.get(file);
|
|
222
|
+
return typeof v === 'string' ? v : null;
|
|
223
|
+
}
|
|
224
|
+
const v = fileContents[file];
|
|
225
|
+
return typeof v === 'string' ? v : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Engine wiring helper ─────────────────────────────────────────────────────
|
|
229
|
+
// Annotates a scan object in place with scan.entrypointInventory. Never throws;
|
|
230
|
+
// absent inputs yield an empty ledger.
|
|
231
|
+
export function annotateEntrypointCoverage(scan) {
|
|
232
|
+
if (!scan || typeof scan !== 'object') return scan;
|
|
233
|
+
try {
|
|
234
|
+
const fileContents = scan.fileContents || scan._fileContents || null;
|
|
235
|
+
const routes = Array.isArray(scan.routes) ? scan.routes : [];
|
|
236
|
+
const findings = Array.isArray(scan.findings) ? scan.findings : [];
|
|
237
|
+
if (!fileContents && routes.length === 0) {
|
|
238
|
+
scan.entrypointInventory = { entrypoints: [], coverage: _emptyCoverage() };
|
|
239
|
+
return scan;
|
|
240
|
+
}
|
|
241
|
+
scan.entrypointInventory = buildEntrypointInventory(fileContents || {}, { routes, findings });
|
|
242
|
+
} catch (_) {
|
|
243
|
+
scan.entrypointInventory = { entrypoints: [], coverage: _emptyCoverage() };
|
|
244
|
+
}
|
|
245
|
+
return scan;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const _internals = { SURFACE_PATTERNS, ENTRY_TYPES, AUTH_TOKEN, _entries };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Promote a finding to execution-proven by running its proof-of-concept inside
|
|
2
|
+
// the confined execution sandbox and observing a real effect.
|
|
3
|
+
//
|
|
4
|
+
// Proof is a file the PoC writes, NOT an exit code: the sandbox cannot reliably
|
|
5
|
+
// distinguish "denied" from "ran and exited 0", so exit status is not evidence.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { runConfined, sandboxAvailable, detectBackend } from '../sandbox/index.js';
|
|
10
|
+
import { attachProofTier, proofTierOf } from './proof-tier.js';
|
|
11
|
+
|
|
12
|
+
const PROOF_MARKER = 'PROVEN';
|
|
13
|
+
|
|
14
|
+
function _evidence(over = {}) {
|
|
15
|
+
return {
|
|
16
|
+
tier: 'taint-proven', backend: detectBackend(), ran: false, observed: null,
|
|
17
|
+
reason: null, exitCode: null, timedOut: false, at: new Date().toISOString(), ...over,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function proveFinding(finding, { timeoutMs = 10000 } = {}) {
|
|
22
|
+
const poc = finding?.poc;
|
|
23
|
+
if (!poc?.code) {
|
|
24
|
+
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
|
|
25
|
+
}
|
|
26
|
+
if (poc.lang !== 'js') {
|
|
27
|
+
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: `unsupported poc language: ${poc.lang}` }));
|
|
28
|
+
}
|
|
29
|
+
if (!sandboxAvailable()) {
|
|
30
|
+
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no confinement primitive available; refusing to execute' }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'proof-')));
|
|
34
|
+
try {
|
|
35
|
+
fs.writeFileSync(path.join(root, 'poc.mjs'), poc.code, 'utf8');
|
|
36
|
+
const r = runConfined([process.execPath, 'poc.mjs'], { root, timeoutMs });
|
|
37
|
+
const proven = fs.existsSync(path.join(root, PROOF_MARKER));
|
|
38
|
+
|
|
39
|
+
return attachProofTier(finding, _evidence({
|
|
40
|
+
tier: proven ? 'execution-proven' : 'proof-failed',
|
|
41
|
+
backend: r.backend,
|
|
42
|
+
ran: !r.timedOut && r.status !== 'disabled',
|
|
43
|
+
observed: proven ? `proof marker '${PROOF_MARKER}' written by the proof-of-concept` : null,
|
|
44
|
+
reason: proven ? null
|
|
45
|
+
: r.timedOut ? 'proof-of-concept exceeded its time budget'
|
|
46
|
+
: 'proof-of-concept ran but did not demonstrate the predicted effect',
|
|
47
|
+
exitCode: r.exitCode, timedOut: r.timedOut,
|
|
48
|
+
}));
|
|
49
|
+
} finally {
|
|
50
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -79,7 +79,7 @@ const FACTORS = [
|
|
|
79
79
|
name: 'source-from-network',
|
|
80
80
|
factor: 1.3,
|
|
81
81
|
test: (f) => (f.trace || f.chain || []).some(t =>
|
|
82
|
-
/http-body|url-param|header|cookie/i.test(t.provenance || '')),
|
|
82
|
+
/http-body|url-param|header|cookie|network/i.test(t.provenance || '')),
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
name: 'critical-severity-detector',
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Addition #1 — Default falsification pass ("prove it can't be blocked, or demote").
|
|
2
|
+
//
|
|
3
|
+
// For each taint-style finding we actively try to DISPROVE it: locate a
|
|
4
|
+
// context-matched control (a sanitizer whose shape actually neutralizes THIS
|
|
5
|
+
// CWE family) on the path between source and sink. A finding that is blocked by
|
|
6
|
+
// such a control is "falsified" — demoted and quarantined. A finding with no
|
|
7
|
+
// blocking control "survives" and stands.
|
|
8
|
+
//
|
|
9
|
+
// This is recall-preserving, exactly like `dataflow/proof-gate.js`: a falsified
|
|
10
|
+
// finding is DEMOTED (confidence + tiers) and flagged `quarantined`, never
|
|
11
|
+
// removed and never severity-touched. Genuine vulnerabilities have no valid
|
|
12
|
+
// control on the path, so they survive — the corpus `pre:TP` fixtures stay TP.
|
|
13
|
+
//
|
|
14
|
+
// An OPTIONAL LLM tier (`opts.llmReview`, wired only when an LLM endpoint is
|
|
15
|
+
// configured) argues the opposing case over survivors; it is never required and
|
|
16
|
+
// the deterministic core runs fully offline.
|
|
17
|
+
|
|
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';
|
|
23
|
+
|
|
24
|
+
const DEMOTE_FACTOR = 0.4; // mirror proof-gate.js
|
|
25
|
+
const TIERS = ['low', 'medium', 'high']; // confidence / exploitability tier order
|
|
26
|
+
|
|
27
|
+
function _dropTier(tier) {
|
|
28
|
+
const i = TIERS.indexOf(tier);
|
|
29
|
+
if (i <= 0) return tier; // unknown or already lowest → unchanged
|
|
30
|
+
return TIERS[i - 1];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _fileText(fileContents, file) {
|
|
34
|
+
if (!fileContents || !file) return '';
|
|
35
|
+
if (fileContents instanceof Map) return fileContents.get(file) || '';
|
|
36
|
+
return fileContents[file] || '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Reconstruct the path window: the source line, the sink line, and the lines
|
|
40
|
+
// between/around the sink, plus whatever snippets the finding already carries.
|
|
41
|
+
function _pathWindow(finding, fileContents) {
|
|
42
|
+
const parts = [];
|
|
43
|
+
if (finding.source?.snippet) parts.push(String(finding.source.snippet));
|
|
44
|
+
if (finding.sink?.snippet) parts.push(String(finding.sink.snippet));
|
|
45
|
+
const text = _fileText(fileContents, finding.file);
|
|
46
|
+
if (text) {
|
|
47
|
+
const lines = text.split('\n');
|
|
48
|
+
const sinkLine = Number(finding.sink?.line) || 0;
|
|
49
|
+
const srcLine = Number(finding.source?.line) || 0;
|
|
50
|
+
const lo = Math.max(0, Math.min(sinkLine, srcLine) - 3);
|
|
51
|
+
const hi = Math.min(lines.length, Math.max(sinkLine, srcLine) + 3);
|
|
52
|
+
for (let i = lo; i < hi; i++) parts.push(lines[i]);
|
|
53
|
+
}
|
|
54
|
+
return parts.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pure classifier. Returns `{ verdict, reasons }` with verdict ∈
|
|
59
|
+
* 'blocked' — a context-matched control for this CWE family sits on the path
|
|
60
|
+
* 'survived' — no blocking control found; the finding stands
|
|
61
|
+
* 'unproven' — not enough context to attempt falsification
|
|
62
|
+
*/
|
|
63
|
+
export function classifyFinding(finding, fileContents) {
|
|
64
|
+
if (!finding || !finding.cwe || !finding.source || !finding.sink) {
|
|
65
|
+
return { verdict: 'unproven', reasons: ['not a taint-style finding'] };
|
|
66
|
+
}
|
|
67
|
+
// A sanitizer that doesn't match the sink context does NOT block the flow —
|
|
68
|
+
// the finding survives (this is a real bug, not a mitigation).
|
|
69
|
+
if (finding.sanitizerMismatch === true) {
|
|
70
|
+
return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
|
|
71
|
+
}
|
|
72
|
+
const window = _pathWindow(finding, fileContents);
|
|
73
|
+
if (!window || !window.trim()) {
|
|
74
|
+
return { verdict: 'unproven', reasons: ['no source context available to attempt falsification'] };
|
|
75
|
+
}
|
|
76
|
+
const v = isValidSanitizerFor(window, finding.cwe);
|
|
77
|
+
if (v.trusted) {
|
|
78
|
+
return { verdict: 'blocked', reasons: [`context-matched control on path — ${v.reason}`] };
|
|
79
|
+
}
|
|
80
|
+
return { verdict: 'survived', reasons: ['no context-matched control found between source and sink'] };
|
|
81
|
+
}
|
|
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
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Default-on annotator. Adds `finding.falsification = { verdict, reasons }` to
|
|
94
|
+
* every taint-style finding; demotes + quarantines the ones falsified as blocked.
|
|
95
|
+
* NEVER removes a finding and NEVER mutates severity (recall-preserving).
|
|
96
|
+
*
|
|
97
|
+
* @param opts.llmReview optional (survivor) => { verdict, reason } — the LLM tier.
|
|
98
|
+
* Wired only when an LLM endpoint is configured; run over
|
|
99
|
+
* survivors, and its result is attached at .falsification.llm.
|
|
100
|
+
*/
|
|
101
|
+
export function annotateFalsification(findings, fileContents, opts = {}) {
|
|
102
|
+
if (!Array.isArray(findings)) return findings;
|
|
103
|
+
const survivors = [];
|
|
104
|
+
for (const f of findings) {
|
|
105
|
+
if (!f || !f.source || !f.sink || !f.cwe) continue; // only taint-style findings
|
|
106
|
+
let res;
|
|
107
|
+
try { res = classifyFinding(f, fileContents); }
|
|
108
|
+
catch { res = { verdict: 'unproven', reasons: ['classification error'] }; }
|
|
109
|
+
f.falsification = { verdict: res.verdict, reasons: res.reasons };
|
|
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
|
+
|
|
128
|
+
if (res.verdict === 'blocked') {
|
|
129
|
+
f.quarantined = true;
|
|
130
|
+
if (typeof f.confidence === 'number') {
|
|
131
|
+
f.confidence = Math.max(0, Math.round(f.confidence * DEMOTE_FACTOR * 1000) / 1000);
|
|
132
|
+
}
|
|
133
|
+
if (f.confidenceTier) f.confidenceTier = _dropTier(f.confidenceTier);
|
|
134
|
+
if (f.exploitabilityTier) f.exploitabilityTier = _dropTier(f.exploitabilityTier);
|
|
135
|
+
// severity intentionally untouched.
|
|
136
|
+
} else if (res.verdict === 'survived') {
|
|
137
|
+
survivors.push(f);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Optional LLM tier — only over survivors, only when a reviewer is supplied.
|
|
142
|
+
if (typeof opts.llmReview === 'function') {
|
|
143
|
+
for (const f of survivors) {
|
|
144
|
+
try {
|
|
145
|
+
const llm = opts.llmReview(f);
|
|
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
|
+
}
|
|
161
|
+
} catch { /* the LLM tier is advisory; never let it break the scan */ }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return findings;
|
|
165
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Deterministic honesty gates on fix / finding output (#7).
|
|
2
|
+
//
|
|
3
|
+
// The project's verification discipline (scanner/CLAUDE.md) exists because
|
|
4
|
+
// several releases shipped broken or false because work was reported as done
|
|
5
|
+
// without confirming the artifact changed. Two of those failure modes are
|
|
6
|
+
// *textual* — they live in the prose an agent emits alongside a fix — and can
|
|
7
|
+
// be caught deterministically, with no LLM and no network:
|
|
8
|
+
//
|
|
9
|
+
// 1. Hand-wave residual-risk prose. "The input is adequately handled",
|
|
10
|
+
// "future work", "tbd", "later" — vague assurances that claim safety
|
|
11
|
+
// without naming a concrete remaining vector. A residual you can't name
|
|
12
|
+
// is a residual you're guessing about; reject the guess.
|
|
13
|
+
//
|
|
14
|
+
// 2. An unbacked "this is a false positive / provably safe" verdict. Marking
|
|
15
|
+
// a finding safe is a coverage *reduction* — it must cite a `file:line`
|
|
16
|
+
// that shows why, exactly like the rules-override gate refuses to silently
|
|
17
|
+
// shrink coverage.
|
|
18
|
+
//
|
|
19
|
+
// Plus a conservative fix-tier classifier so a partial remediation can never be
|
|
20
|
+
// labelled FULL: any workaround-only signal (rate-limit, docs, log-without-
|
|
21
|
+
// reject) is WORKAROUND; anything short of (sink signature changed + all callers
|
|
22
|
+
// routed + a discriminating test) is at most MITIGATION; only the full set with
|
|
23
|
+
// no partial-sanitization caveat earns FULL.
|
|
24
|
+
//
|
|
25
|
+
// Pure functions, no side effects, no throwing — safe to call from a command,
|
|
26
|
+
// a hook, or the MCP verify_fix path.
|
|
27
|
+
|
|
28
|
+
// Vague-assurance phrases that a real residual must never hide behind. Matched
|
|
29
|
+
// case-insensitively with word boundaries so "later" doesn't trip on
|
|
30
|
+
// "collateral" and "tbd" doesn't trip on a longer token.
|
|
31
|
+
const BANNED_RESIDUAL_PHRASES = Object.freeze([
|
|
32
|
+
'adequately handled',
|
|
33
|
+
'adequately handles',
|
|
34
|
+
'properly validated',
|
|
35
|
+
'properly handled',
|
|
36
|
+
'handled properly',
|
|
37
|
+
'handled safely',
|
|
38
|
+
'future work',
|
|
39
|
+
'more work needed',
|
|
40
|
+
'to be done',
|
|
41
|
+
'tbd',
|
|
42
|
+
'later',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// A citation shaped like `file:line` — one or more non-space, non-colon chars,
|
|
46
|
+
// a colon, then digits. Unanchored: it need only appear somewhere in the item.
|
|
47
|
+
const CITATION_RE = /[^\s:]+:\d+/;
|
|
48
|
+
|
|
49
|
+
// Verdicts that assert the finding is not real and therefore demand a citation.
|
|
50
|
+
// Compared after normalizing separators (`_`/space → `-`) and lowercasing, so
|
|
51
|
+
// FALSE_POSITIVE, false-positive, and "provably safe" all land here.
|
|
52
|
+
const FP_VERDICTS = Object.freeze(new Set(['false-positive', 'provably-safe', 'safe']));
|
|
53
|
+
|
|
54
|
+
function _escapeRe(s) {
|
|
55
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reject vague-assurance / hand-wave residual-risk prose.
|
|
60
|
+
*
|
|
61
|
+
* An empty or whitespace-only residual is ok — there is no residual to lie
|
|
62
|
+
* about. A non-empty residual is rejected when it contains any banned phrase;
|
|
63
|
+
* each match yields one violation naming the offending phrase.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} residualText
|
|
66
|
+
* @returns {{ ok: boolean, violations: string[] }}
|
|
67
|
+
*/
|
|
68
|
+
export function checkResidualHonesty(residualText) {
|
|
69
|
+
const text = typeof residualText === 'string' ? residualText : '';
|
|
70
|
+
if (text.trim() === '') return { ok: true, violations: [] };
|
|
71
|
+
|
|
72
|
+
const violations = [];
|
|
73
|
+
for (const phrase of BANNED_RESIDUAL_PHRASES) {
|
|
74
|
+
const re = new RegExp(`\\b${_escapeRe(phrase)}\\b`, 'i');
|
|
75
|
+
if (re.test(text)) {
|
|
76
|
+
violations.push(`vague-assurance phrase: "${phrase}"`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { ok: violations.length === 0, violations };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _isCitation(item) {
|
|
83
|
+
if (typeof item === 'string') return CITATION_RE.test(item);
|
|
84
|
+
if (item && typeof item === 'object' && typeof item.location === 'string') {
|
|
85
|
+
return CITATION_RE.test(item.location);
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _normalizeVerdict(verdict) {
|
|
91
|
+
return String(verdict).trim().toLowerCase().replace(/[_\s]+/g, '-');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Require a file:line citation behind a "this is not real" verdict.
|
|
96
|
+
*
|
|
97
|
+
* For a false-positive / provably-safe / safe verdict (case-insensitive; also
|
|
98
|
+
* accepts FALSE_POSITIVE), at least one evidence item must be a `file:line`
|
|
99
|
+
* citation — either a string matching /[^\s:]+:\d+/ or an object
|
|
100
|
+
* `{ location: "file:line" }`. Any other verdict passes unconditionally.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} verdict
|
|
103
|
+
* @param {Array|string|object} evidence
|
|
104
|
+
* @returns {{ ok: boolean, violations: string[] }}
|
|
105
|
+
*/
|
|
106
|
+
export function requireCitedEvidence(verdict, evidence) {
|
|
107
|
+
if (typeof verdict !== 'string' || !FP_VERDICTS.has(_normalizeVerdict(verdict))) {
|
|
108
|
+
return { ok: true, violations: [] };
|
|
109
|
+
}
|
|
110
|
+
const items = Array.isArray(evidence)
|
|
111
|
+
? evidence
|
|
112
|
+
: evidence == null
|
|
113
|
+
? []
|
|
114
|
+
: [evidence];
|
|
115
|
+
if (items.some(_isCitation)) return { ok: true, violations: [] };
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
violations: ['false-positive/safe verdict requires a file:line citation'],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Classify a fix into FULL | MITIGATION | WORKAROUND, conservative-first.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} signals
|
|
126
|
+
* @param {boolean} signals.sinkSignatureChanged
|
|
127
|
+
* @param {boolean} signals.allCallersRouted
|
|
128
|
+
* @param {boolean} signals.testDiscriminates - a test that fails pre-fix, passes post-fix
|
|
129
|
+
* @param {boolean} [signals.rateLimitOnly]
|
|
130
|
+
* @param {boolean} [signals.docsOnly]
|
|
131
|
+
* @param {boolean} [signals.logOnlyNoReject]
|
|
132
|
+
* @param {boolean} [signals.partialSanitization]
|
|
133
|
+
* @returns {'FULL'|'MITIGATION'|'WORKAROUND'}
|
|
134
|
+
*/
|
|
135
|
+
export function computeFixTier(signals) {
|
|
136
|
+
const s = signals && typeof signals === 'object' ? signals : {};
|
|
137
|
+
if (s.rateLimitOnly || s.docsOnly || s.logOnlyNoReject) return 'WORKAROUND';
|
|
138
|
+
const complete = s.sinkSignatureChanged && s.allCallersRouted && s.testDiscriminates;
|
|
139
|
+
if (s.partialSanitization || !complete) return 'MITIGATION';
|
|
140
|
+
return 'FULL';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Compose the three gates for a single fix's output.
|
|
145
|
+
*
|
|
146
|
+
* ok = residual-honesty ok AND evidence-citation ok, further constrained by the
|
|
147
|
+
* tier/residual consistency invariant:
|
|
148
|
+
* - a FULL tier must NOT carry a residual (a full fix has nothing left);
|
|
149
|
+
* - a non-FULL tier MUST document a residual (say what's still open).
|
|
150
|
+
*
|
|
151
|
+
* @param {{ residual?: string, verdict?: string, evidence?: any, signals?: object }} input
|
|
152
|
+
* @returns {{ ok: boolean, tier: string, violations: string[] }}
|
|
153
|
+
*/
|
|
154
|
+
export function gateFixOutput({ residual, verdict, evidence, signals } = {}) {
|
|
155
|
+
const tier = computeFixTier(signals);
|
|
156
|
+
const residualCheck = checkResidualHonesty(residual);
|
|
157
|
+
const evidenceCheck = requireCitedEvidence(verdict, evidence);
|
|
158
|
+
|
|
159
|
+
const violations = [...residualCheck.violations, ...evidenceCheck.violations];
|
|
160
|
+
let ok = residualCheck.ok && evidenceCheck.ok;
|
|
161
|
+
|
|
162
|
+
const residualEmpty = typeof residual !== 'string' || residual.trim() === '';
|
|
163
|
+
if (tier === 'FULL' && !residualEmpty) {
|
|
164
|
+
violations.push('FULL tier cannot carry a residual');
|
|
165
|
+
ok = false;
|
|
166
|
+
}
|
|
167
|
+
if (tier !== 'FULL' && residualEmpty) {
|
|
168
|
+
violations.push('non-FULL tier must document a residual');
|
|
169
|
+
ok = false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { ok, tier, violations };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const _internals = Object.freeze({ BANNED_RESIDUAL_PHRASES, CITATION_RE, FP_VERDICTS });
|