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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +727 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +19 -8
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/826.index.js +4 -1
  13. package/dist/830.index.js +1 -1
  14. package/dist/agentic-security.mjs +113 -163
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/package.json +23 -15
  17. package/src/dataflow/CLAUDE.md +4 -1
  18. package/src/dataflow/async-sequencing.js +8 -3
  19. package/src/dataflow/catalog.js +278 -11
  20. package/src/dataflow/cross-repo.js +1 -1
  21. package/src/dataflow/cross-service-taint.js +1 -1
  22. package/src/dataflow/engine.js +182 -61
  23. package/src/dataflow/ifds.js +10 -5
  24. package/src/dataflow/index.js +15 -3
  25. package/src/dataflow/points-to.js +8 -2
  26. package/src/dataflow/proof-gate.js +7 -0
  27. package/src/dataflow/sanitizer-gate.js +89 -0
  28. package/src/dataflow/tabulation.js +14 -3
  29. package/src/engine.js +181 -8
  30. package/src/integrations/index.js +1 -1
  31. package/src/integrations/tickets.js +9 -3
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +5 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +17 -6
  49. package/src/posture/CLAUDE.md +122 -0
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +199 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/cross-lang-openapi.js +1 -1
  56. package/src/posture/custom-rules.js +1 -1
  57. package/src/posture/entrypoint-inventory.js +248 -0
  58. package/src/posture/execution-proof.js +52 -0
  59. package/src/posture/exploitability-probability.js +1 -1
  60. package/src/posture/falsification.js +165 -0
  61. package/src/posture/fix-honesty-gate.js +175 -0
  62. package/src/posture/fix-verify.js +71 -3
  63. package/src/posture/license-policy.js +1 -1
  64. package/src/posture/model-routing.js +126 -0
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +262 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/pr-comment.js +3 -1
  76. package/src/report/index.js +11 -0
  77. package/src/runScan.js +3 -1
  78. package/src/sandbox/CLAUDE.md +218 -0
  79. package/src/sandbox/backend-disabled.js +14 -0
  80. package/src/sandbox/backend-namespace.js +83 -0
  81. package/src/sandbox/backend-userspace.js +100 -0
  82. package/src/sandbox/capabilities.js +53 -0
  83. package/src/sandbox/index.js +30 -0
  84. package/src/sandbox/limits.js +42 -0
  85. package/src/sandbox/result.js +104 -0
  86. package/src/sca/dep-confusion.js +1 -1
  87. package/src/util/untrusted.js +148 -0
  88. package/src/util/yaml.js +24 -0
@@ -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 });