@clear-capabilities/agentic-security-scanner 0.132.0 → 0.134.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 +228 -0
- package/bin/agentic-security.js +103 -1
- package/dist/113.index.js +3 -3
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/499.index.js +86 -0
- package/dist/526.index.js +3 -3
- package/dist/609.index.js +741 -0
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +56 -56
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +38 -0
- package/src/discovery/confirm.js +47 -0
- package/src/discovery/disprove.js +79 -0
- package/src/discovery/hunter.js +116 -0
- package/src/discovery/index.js +159 -0
- package/src/discovery/judge.js +97 -0
- package/src/discovery/lenses.js +69 -0
- package/src/discovery/llm-invoke.js +31 -0
- package/src/discovery/partition.js +92 -0
- package/src/engine.js +151 -1
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +254 -35
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/llm-validator/providers.js +227 -0
- package/src/posture/CLAUDE.md +76 -0
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/autopilot.js +225 -0
- package/src/posture/comparison.js +181 -0
- package/src/posture/corpus-match.js +29 -14
- package/src/posture/execution-proof.js +25 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/logic-claims.js +266 -0
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +567 -0
- package/src/posture/proof-artifact.js +101 -0
- package/src/posture/prove-findings.js +172 -0
- package/src/posture/rule-overrides.js +64 -3
- package/src/posture/state-dir.js +25 -0
- package/src/posture/vuln-archaeology.js +231 -0
- package/src/report/index.js +16 -0
- package/src/sandbox/CLAUDE.md +27 -5
- package/src/sandbox/backend-namespace.js +39 -11
- package/src/sandbox/backend-userspace.js +4 -0
- package/src/sast/CLAUDE.md +4 -0
- package/src/sast/crypto-specialist.js +247 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
// In-process proof-of-concept synthesis (R2 — closing the automatic loop).
|
|
2
|
+
//
|
|
3
|
+
// WHY A SECOND POC KIND. `poc-generator.js` emits *HTTP* PoCs: they take a URL
|
|
4
|
+
// and a param and hit a running server. Those are the right artifact for a
|
|
5
|
+
// human reproducing a bug against a deployed app, but they cannot be executed
|
|
6
|
+
// by `execution-proof.js` — the sandbox denies network egress and there is no
|
|
7
|
+
// server inside it. That is the concrete reason nothing in the scan pipeline
|
|
8
|
+
// ever produced an `execution-proven` finding: the PoCs it generated were
|
|
9
|
+
// unrunnable *by construction* in the only place proofs are allowed to run.
|
|
10
|
+
//
|
|
11
|
+
// This module emits the other kind: a self-contained script that imports the
|
|
12
|
+
// vulnerable file directly, calls the exported handler with a crafted request,
|
|
13
|
+
// and lets the injected payload write the proof marker. No server, no network,
|
|
14
|
+
// no fixture discovery.
|
|
15
|
+
//
|
|
16
|
+
// DELIBERATELY NARROW. It refuses far more than it accepts, and that is the
|
|
17
|
+
// design rather than a gap to close later. A PoC that runs but proves nothing
|
|
18
|
+
// is worse than no PoC: `proof-failed` is a triage signal about the FINDING,
|
|
19
|
+
// so a template that misfires manufactures evidence against real bugs. Every
|
|
20
|
+
// refusal below is a case where the generated script would have been a guess.
|
|
21
|
+
//
|
|
22
|
+
// SAFETY. The payload is a shell redirect that creates a marker file inside
|
|
23
|
+
// the sandbox root (`> PROVEN`). It is the least destructive thing that still
|
|
24
|
+
// proves arbitrary command execution: no network, no writes outside the root,
|
|
25
|
+
// no privilege attempts, and it is only ever executed inside R1's sandbox with
|
|
26
|
+
// egress denied and the filesystem confined.
|
|
27
|
+
|
|
28
|
+
const MARKER = 'PROVEN';
|
|
29
|
+
|
|
30
|
+
// Only families where "the injected payload ran" is observable from a marker
|
|
31
|
+
// file. Others (XSS, weak crypto) need a browser or a judgement call about
|
|
32
|
+
// output, and a marker-file proof would be a category error.
|
|
33
|
+
// Families whose exploitation is observable from inside a sandbox.
|
|
34
|
+
//
|
|
35
|
+
// Injection classes are observable because the payload itself writes the
|
|
36
|
+
// marker. Webhook-signature bypass is observable for a different reason: the
|
|
37
|
+
// defect IS the handler accepting a request it should have rejected, so the
|
|
38
|
+
// PoC observes the acceptance and records it. Both are execution-based — code
|
|
39
|
+
// ran and behaved insecurely — but they are proven by different evidence, and
|
|
40
|
+
// conflating them is how a template ends up asserting something it never saw.
|
|
41
|
+
const SUPPORTED = new Set([
|
|
42
|
+
'command-injection',
|
|
43
|
+
'code-injection',
|
|
44
|
+
'webhook-missing-signature-verification',
|
|
45
|
+
'sql-injection',
|
|
46
|
+
'path-traversal',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// Classes proven by observing the HANDLER's behaviour rather than a payload
|
|
50
|
+
// side effect. Each has its own builder below; the shared refusals (language,
|
|
51
|
+
// content, module system) are applied before dispatch.
|
|
52
|
+
const BEHAVIOURAL = new Map([
|
|
53
|
+
['webhook-missing-signature-verification', (f, c) => _webhookPoc(f, c)],
|
|
54
|
+
['sql-injection', (f, c) => _sqlInjectionPoc(f, c)],
|
|
55
|
+
['path-traversal', (f, c) => _pathTraversalPoc(f, c)],
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// Classes deliberately NOT here, with the reason, so the gap is a decision
|
|
59
|
+
// rather than an oversight:
|
|
60
|
+
//
|
|
61
|
+
// IDOR / broken-access-control — proving it means showing user A read user
|
|
62
|
+
// B's record, which requires two authenticated identities and a populated
|
|
63
|
+
// data store. A single-shot harness would have to invent both, and a PoC
|
|
64
|
+
// built on invented state proves something about the invention.
|
|
65
|
+
// SSRF — the proof is that the server fetched an attacker-named host. The
|
|
66
|
+
// sandbox denies egress (that is the point of R1), and binding a loopback
|
|
67
|
+
// listener is not guaranteed across backends, so a failed fetch would be
|
|
68
|
+
// confinement talking, not the finding.
|
|
69
|
+
// XSS — needs a browser to say whether the payload executed. A marker file
|
|
70
|
+
// cannot observe a DOM.
|
|
71
|
+
|
|
72
|
+
const JS_EXT = /\.(js|cjs|mjs)$/i;
|
|
73
|
+
|
|
74
|
+
// A handler we can call: `module.exports = function (req, res)` or
|
|
75
|
+
// `module.exports.name = function (req, res)` / `exports.name = ...`.
|
|
76
|
+
// Arrow and function forms both count. The two-parameter (req, res) shape is
|
|
77
|
+
// required — a one-arg export is not an Express-style handler and calling it
|
|
78
|
+
// with a fake request would be inventing an interface.
|
|
79
|
+
const HANDLER_RES = [
|
|
80
|
+
/module\.exports\s*=\s*(?:async\s+)?function\s*\w*\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)/,
|
|
81
|
+
/module\.exports\s*=\s*(?:async\s+)?\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>/,
|
|
82
|
+
];
|
|
83
|
+
const NAMED_HANDLER_RES = [
|
|
84
|
+
/(?:module\.)?exports\.(\w+)\s*=\s*(?:async\s+)?function\s*\w*\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)/,
|
|
85
|
+
/(?:module\.)?exports\.(\w+)\s*=\s*(?:async\s+)?\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>/,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// The exported handler, default form preferred over a named one. Shared by
|
|
89
|
+
// every template — a template that found handlers its own way could accept a
|
|
90
|
+
// shape the others refuse, and the refusals are the safety property here.
|
|
91
|
+
function _findHandler(fileContent) {
|
|
92
|
+
for (const re of HANDLER_RES) {
|
|
93
|
+
const m = fileContent.match(re);
|
|
94
|
+
if (m) return { call: { kind: 'default', name: null }, reqIdent: m[1] };
|
|
95
|
+
}
|
|
96
|
+
for (const re of NAMED_HANDLER_RES) {
|
|
97
|
+
const m = fileContent.match(re);
|
|
98
|
+
if (m) return { call: { kind: 'named', name: m[1] }, reqIdent: m[2] };
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const NO_HANDLER = {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: 'no exported two-argument (req, res) handler found — nothing to call without inventing an interface',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Import/invoke lines for a located handler.
|
|
109
|
+
function _binding(finding, call) {
|
|
110
|
+
const base = finding.file.split(/[\\/]/).pop();
|
|
111
|
+
return {
|
|
112
|
+
base,
|
|
113
|
+
importLine: `import ${call.kind === 'default' ? 'handler' : `{ ${call.name} }`} from './${base}';`,
|
|
114
|
+
invoke: call.kind === 'default' ? 'handler' : call.name,
|
|
115
|
+
handlerLabel: call.kind === 'default' ? 'module.exports' : `exports.${call.name}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The request property the handler reads. Anchored to the request identifier
|
|
120
|
+
// the export actually binds, so a file that reads `req.query` while exporting
|
|
121
|
+
// `(request, response)` does not produce a PoC built on the wrong name.
|
|
122
|
+
function _requestSource(content, reqIdent) {
|
|
123
|
+
const esc = reqIdent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
124
|
+
for (const prop of ['query', 'body', 'params']) {
|
|
125
|
+
const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)`);
|
|
126
|
+
const m = content.match(re);
|
|
127
|
+
if (m) return { prop, key: m[1] };
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The sink must interpolate into a SHELL, not an argv array. `exec`/`execSync`
|
|
133
|
+
// run through a shell so `; > PROVEN` executes; `execFile`/`spawn` with an
|
|
134
|
+
// array do not, and a marker PoC against those would fail for a reason that
|
|
135
|
+
// has nothing to do with whether the finding is real.
|
|
136
|
+
const SHELL_SINK = /\b(?:exec|execSync)\s*\(/;
|
|
137
|
+
const ARGV_SINK = /\b(?:execFile|execFileSync|spawn|spawnSync)\s*\(/;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Synthesize a sandbox-runnable PoC, or return a refusal explaining why not.
|
|
141
|
+
*
|
|
142
|
+
* @returns {{ok:true, poc:object} | {ok:false, reason:string}}
|
|
143
|
+
*/
|
|
144
|
+
export function synthesizeInProcessPoc(finding, fileContent) {
|
|
145
|
+
if (!finding || typeof finding !== 'object') return { ok: false, reason: 'no finding' };
|
|
146
|
+
if (!SUPPORTED.has(finding.family)) {
|
|
147
|
+
return { ok: false, reason: `family '${finding.family || 'unknown'}' has no marker-observable in-process template` };
|
|
148
|
+
}
|
|
149
|
+
if (!finding.file || !JS_EXT.test(finding.file)) {
|
|
150
|
+
return { ok: false, reason: 'in-process PoCs are JavaScript-only today' };
|
|
151
|
+
}
|
|
152
|
+
if (typeof fileContent !== 'string' || !fileContent.trim()) {
|
|
153
|
+
return { ok: false, reason: 'the vulnerable file content was not available' };
|
|
154
|
+
}
|
|
155
|
+
if (/^\s*(?:import|export)\s/m.test(fileContent) && !/module\.exports/.test(fileContent)) {
|
|
156
|
+
return { ok: false, reason: 'ES-module source: the CommonJS handler shapes do not apply' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const behavioural = BEHAVIOURAL.get(finding.family);
|
|
160
|
+
if (behavioural) return behavioural(finding, fileContent);
|
|
161
|
+
|
|
162
|
+
if (!SHELL_SINK.test(fileContent)) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
reason: ARGV_SINK.test(fileContent)
|
|
166
|
+
? 'the sink passes an argv array, so a shell-metacharacter payload would not execute — absence of proof here would say nothing about the finding'
|
|
167
|
+
: 'no shell-executing sink found in the file',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Default export first, then a named one.
|
|
172
|
+
let call = null, reqIdent = null;
|
|
173
|
+
for (const re of HANDLER_RES) {
|
|
174
|
+
const m = fileContent.match(re);
|
|
175
|
+
if (m) { call = { kind: 'default', name: null }; reqIdent = m[1]; break; }
|
|
176
|
+
}
|
|
177
|
+
if (!call) {
|
|
178
|
+
for (const re of NAMED_HANDLER_RES) {
|
|
179
|
+
const m = fileContent.match(re);
|
|
180
|
+
if (m) { call = { kind: 'named', name: m[1] }; reqIdent = m[2]; break; }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!call) {
|
|
184
|
+
return { ok: false, reason: 'no exported two-argument (req, res) handler found — nothing to call without inventing an interface' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const src = _requestSource(fileContent, reqIdent);
|
|
188
|
+
if (!src) {
|
|
189
|
+
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const base = finding.file.split(/[\\/]/).pop();
|
|
193
|
+
const imported = call.kind === 'default' ? 'handler' : `{ ${call.name} }`;
|
|
194
|
+
const invoke = call.kind === 'default' ? 'handler' : call.name;
|
|
195
|
+
|
|
196
|
+
// The timer is unref'd so the process exits as soon as the handler responds.
|
|
197
|
+
// Without that it stays alive for the full timeout even after the exploit
|
|
198
|
+
// has landed, and on a loaded machine it can outlive the proof budget — at
|
|
199
|
+
// which point `attachProofTier` demotes a real proof to its static tier
|
|
200
|
+
// because `ran` is false. Correct, but it throws away a genuine result.
|
|
201
|
+
const code = [
|
|
202
|
+
`// Auto-generated in-process proof-of-concept for ${finding.file}.`,
|
|
203
|
+
'// Proves arbitrary command execution by having the injected payload',
|
|
204
|
+
`// create the marker file '${MARKER}' inside the sandbox root.`,
|
|
205
|
+
`import ${imported} from './${base}';`,
|
|
206
|
+
'await new Promise((resolve) => {',
|
|
207
|
+
' const res = {',
|
|
208
|
+
' send: () => resolve(), json: () => resolve(), end: () => resolve(),',
|
|
209
|
+
' status: () => ({ send: () => resolve(), json: () => resolve(), end: () => resolve() }),',
|
|
210
|
+
' };',
|
|
211
|
+
` const req = { ${src.prop}: ${JSON.stringify({ [src.key]: `x; > ${MARKER}` })} };`,
|
|
212
|
+
` try { ${invoke}(req, res); } catch { resolve(); }`,
|
|
213
|
+
' setTimeout(resolve, 4000).unref();',
|
|
214
|
+
'});',
|
|
215
|
+
].join('\n');
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
poc: {
|
|
220
|
+
lang: 'js',
|
|
221
|
+
kind: 'in-process',
|
|
222
|
+
family: finding.family,
|
|
223
|
+
cwe: finding.cwe || null,
|
|
224
|
+
marker: MARKER,
|
|
225
|
+
paramKey: src.key,
|
|
226
|
+
paramSource: src.prop,
|
|
227
|
+
handler: call.kind === 'default' ? 'module.exports' : `exports.${call.name}`,
|
|
228
|
+
// The file the PoC imports. `execution-proof.js` materialises this into
|
|
229
|
+
// the sandbox root; without it the import fails and nothing is proved.
|
|
230
|
+
requires: [base],
|
|
231
|
+
code,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
// ── Webhook signature bypass ────────────────────────────────────────────────
|
|
239
|
+
//
|
|
240
|
+
// The proof is that an UNSIGNED request is processed. So the PoC calls the
|
|
241
|
+
// handler with no signature header and watches what the handler does with the
|
|
242
|
+
// response object: a success reply means the payload was accepted, and the
|
|
243
|
+
// marker is written only then.
|
|
244
|
+
//
|
|
245
|
+
// REFUSALS MATTER MORE HERE THAN FOR INJECTION. A handler that rejects the
|
|
246
|
+
// request produces no marker, which is correct — but a handler that never
|
|
247
|
+
// replies at all also produces no marker, and those are different facts. The
|
|
248
|
+
// template therefore refuses any handler it cannot observe a decision from,
|
|
249
|
+
// rather than letting "no reply" masquerade as "rejected".
|
|
250
|
+
function _webhookPoc(finding, fileContent) {
|
|
251
|
+
const found = _findHandler(fileContent);
|
|
252
|
+
if (!found) return NO_HANDLER;
|
|
253
|
+
const { call, reqIdent } = found;
|
|
254
|
+
// The handler must actually read the body, or "it accepted an unsigned
|
|
255
|
+
// request" is not a statement about a webhook at all.
|
|
256
|
+
const esc = reqIdent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
257
|
+
if (!new RegExp(`\\b${esc}\\.(?:body|rawBody)\\b`).test(fileContent)) {
|
|
258
|
+
return { ok: false, reason: `the handler does not read '${reqIdent}.body', so it is not processing a webhook payload` };
|
|
259
|
+
}
|
|
260
|
+
// If the file verifies a signature somewhere, this template cannot tell
|
|
261
|
+
// whether the check is reachable on this path — that is a taint question, not
|
|
262
|
+
// an execution one. Refuse rather than guess.
|
|
263
|
+
if (/\b(?:createHmac|timingSafeEqual|verifySignature|constructEvent|hash_equals|X-Hub-Signature|stripe-signature)\b/i.test(fileContent)) {
|
|
264
|
+
return { ok: false, reason: 'the file already references signature verification; whether it guards this path is a static question, not one this PoC can answer' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { base, importLine, invoke, handlerLabel } = _binding(finding, call);
|
|
268
|
+
|
|
269
|
+
const code = [
|
|
270
|
+
`// Auto-generated proof for ${finding.file}.`,
|
|
271
|
+
'// Sends a webhook payload with NO signature header. If the handler replies',
|
|
272
|
+
`// with success, it accepted an unsigned request — the marker '${MARKER}' is`,
|
|
273
|
+
'// written only on that observation.',
|
|
274
|
+
importLine,
|
|
275
|
+
"import fs from 'node:fs';",
|
|
276
|
+
'let decided = null;',
|
|
277
|
+
'const ok = () => { if (decided === null) decided = "accepted"; };',
|
|
278
|
+
'const rejected = () => { if (decided === null) decided = "rejected"; };',
|
|
279
|
+
'await new Promise((resolve) => {',
|
|
280
|
+
// The timer is REF'D and cleared on decision, not unref'd. The marker write
|
|
281
|
+
// below happens AFTER this await, so an unref'd timer lets Node exit with
|
|
282
|
+
// the promise still pending the moment a handler declines to reply — the
|
|
283
|
+
// check never runs, and the test asserting "no decision writes no marker"
|
|
284
|
+
// would pass without ever reaching the line it is testing.
|
|
285
|
+
' let timer = null;',
|
|
286
|
+
' const done = () => { clearTimeout(timer); resolve(); };',
|
|
287
|
+
' const res = {',
|
|
288
|
+
' send: () => { ok(); done(); }, json: () => { ok(); done(); }, end: () => { ok(); done(); },',
|
|
289
|
+
' // A 4xx/5xx is a rejection, whatever the body says.',
|
|
290
|
+
' status: (c) => { if (c >= 400) rejected(); else ok();',
|
|
291
|
+
' return { send: done, json: done, end: done }; },',
|
|
292
|
+
' };',
|
|
293
|
+
' const req = { headers: {}, body: { amount: 1, id: "poc" }, rawBody: "{}" };',
|
|
294
|
+
// Armed BEFORE the call. A handler that replies synchronously would
|
|
295
|
+
// otherwise clear a null handle and then arm a timer nobody cancels,
|
|
296
|
+
// holding the process open for the full budget after the work is done.
|
|
297
|
+
' timer = setTimeout(resolve, 3000);',
|
|
298
|
+
` try { ${invoke}(req, res); } catch { decided = "threw"; done(); }`,
|
|
299
|
+
'});',
|
|
300
|
+
'// Only an observed acceptance proves the bypass. "No decision" is not',
|
|
301
|
+
'// acceptance, and must not write the marker.',
|
|
302
|
+
`if (decided === "accepted") fs.writeFileSync('${MARKER}', 'x');`,
|
|
303
|
+
].join('\n');
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
ok: true,
|
|
307
|
+
poc: {
|
|
308
|
+
lang: 'js', kind: 'in-process', family: finding.family, cwe: finding.cwe || null,
|
|
309
|
+
marker: MARKER, handler: handlerLabel,
|
|
310
|
+
observes: 'handler accepted an unsigned webhook payload',
|
|
311
|
+
requires: [base], code,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
// ── SQL injection, proven at the driver boundary ────────────────────────────
|
|
318
|
+
//
|
|
319
|
+
// There is no database in the sandbox and there is not going to be one. What
|
|
320
|
+
// there IS, and what actually settles the question, is the moment the query
|
|
321
|
+
// crosses into the driver: either the user's payload arrives inside the SQL
|
|
322
|
+
// TEXT, or it arrives as a bound parameter. The first is the vulnerability by
|
|
323
|
+
// definition; the second is the fix by definition. Nothing about schema, rows,
|
|
324
|
+
// or a live server is needed to tell them apart.
|
|
325
|
+
//
|
|
326
|
+
// So the PoC stubs the driver package with a recorder, calls the handler with a
|
|
327
|
+
// payload carrying a sentinel, and writes the marker only if the sentinel shows
|
|
328
|
+
// up inside a string that is recognisably SQL. A parameterised call records the
|
|
329
|
+
// sentinel in the params array and the marker is not written — which is the
|
|
330
|
+
// correct outcome, reached by execution rather than by reading the source.
|
|
331
|
+
//
|
|
332
|
+
// This is why the class needed no running app: the proof was never at the
|
|
333
|
+
// database, it was at the boundary.
|
|
334
|
+
|
|
335
|
+
const SQL_DRIVERS = ['mysql2', 'mysql', 'pg', 'sqlite3', 'better-sqlite3', 'mssql', 'oracledb'];
|
|
336
|
+
const SQL_SENTINEL = 'PROVEN_SQLI';
|
|
337
|
+
const SQL_LOG = 'driver-calls.jsonl';
|
|
338
|
+
|
|
339
|
+
// A universal recording stub. The drivers differ in surface (`createConnection`
|
|
340
|
+
// vs `new Pool` vs `new Database`), so rather than model each one, every
|
|
341
|
+
// property access yields a callable/constructible proxy that records its string
|
|
342
|
+
// and array arguments and drives any callback. `then` is undefined so an
|
|
343
|
+
// `await` on a result does not hang.
|
|
344
|
+
const DRIVER_STUB = [
|
|
345
|
+
"const fs = require('fs');",
|
|
346
|
+
'function record(args) {',
|
|
347
|
+
' const strings = [], arrays = [];',
|
|
348
|
+
' for (const a of args) {',
|
|
349
|
+
" if (typeof a === 'string') strings.push(a);",
|
|
350
|
+
' else if (Array.isArray(a)) arrays.push(a.map((x) => String(x)));',
|
|
351
|
+
// mysql's `query({sql, values})` form, and pg's `query({text, values})`.
|
|
352
|
+
" else if (a && typeof a === 'object') {",
|
|
353
|
+
" if (typeof a.sql === 'string') strings.push(a.sql);",
|
|
354
|
+
" if (typeof a.text === 'string') strings.push(a.text);",
|
|
355
|
+
' if (Array.isArray(a.values)) arrays.push(a.values.map((x) => String(x)));',
|
|
356
|
+
' }',
|
|
357
|
+
' }',
|
|
358
|
+
' if (strings.length || arrays.length) {',
|
|
359
|
+
` try { fs.appendFileSync(${JSON.stringify(SQL_LOG)}, JSON.stringify({ strings, arrays }) + '\\n'); } catch {}`,
|
|
360
|
+
' }',
|
|
361
|
+
'}',
|
|
362
|
+
'const mk = () => new Proxy(function () {}, {',
|
|
363
|
+
" get(t, p) { if (typeof p === 'symbol' || p === 'then' || p === 'inspect') return undefined; return mk(); },",
|
|
364
|
+
' apply(t, self, args) {',
|
|
365
|
+
' record(args);',
|
|
366
|
+
" for (const a of args) if (typeof a === 'function') { try { a(null, []); } catch {} }",
|
|
367
|
+
' return mk();',
|
|
368
|
+
' },',
|
|
369
|
+
' construct(t, args) { record(args); return mk(); },',
|
|
370
|
+
'});',
|
|
371
|
+
'module.exports = mk();',
|
|
372
|
+
].join('\n');
|
|
373
|
+
|
|
374
|
+
// Compiled once at load rather than per call. Building these from a template
|
|
375
|
+
// inside the function meant a fresh RegExp per driver per finding, and it read
|
|
376
|
+
// as a dynamically-assembled pattern to anything analysing this file — this
|
|
377
|
+
// engine included, which flagged it.
|
|
378
|
+
const SQL_DRIVER_RES = SQL_DRIVERS.map((d) => [
|
|
379
|
+
d,
|
|
380
|
+
new RegExp(`require\\s*\\(\\s*['"\`]${d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`),
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
function _sqlInjectionPoc(finding, fileContent) {
|
|
384
|
+
const driver = (SQL_DRIVER_RES.find(([, re]) => re.test(fileContent)) || [])[0];
|
|
385
|
+
if (!driver) {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
reason: `no recognised database driver is required by this file, so there is no boundary to observe the query at (looked for: ${SQL_DRIVERS.join(', ')})`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const found = _findHandler(fileContent);
|
|
392
|
+
if (!found) return NO_HANDLER;
|
|
393
|
+
const { call, reqIdent } = found;
|
|
394
|
+
|
|
395
|
+
const src = _requestSource(fileContent, reqIdent);
|
|
396
|
+
if (!src) {
|
|
397
|
+
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const { base, importLine, invoke, handlerLabel } = _binding(finding, call);
|
|
401
|
+
// Carries SQL syntax AND the sentinel: the sentinel alone would also appear
|
|
402
|
+
// in a correctly parameterised call, and matching on it would call a fixed
|
|
403
|
+
// handler vulnerable.
|
|
404
|
+
const payload = `1' OR '1'='1' -- ${SQL_SENTINEL}`;
|
|
405
|
+
|
|
406
|
+
const code = [
|
|
407
|
+
`// Auto-generated proof for ${finding.file}.`,
|
|
408
|
+
`// Stubs the '${driver}' driver with a recorder and calls the handler with a`,
|
|
409
|
+
'// payload carrying SQL syntax. The marker is written only if the payload',
|
|
410
|
+
'// arrived inside the QUERY TEXT — a bound parameter proves the opposite.',
|
|
411
|
+
importLine,
|
|
412
|
+
"import fs from 'node:fs';",
|
|
413
|
+
'await new Promise((resolve) => {',
|
|
414
|
+
' let timer = null;',
|
|
415
|
+
' const done = () => { clearTimeout(timer); resolve(); };',
|
|
416
|
+
' const res = {',
|
|
417
|
+
' send: done, json: done, end: done,',
|
|
418
|
+
' status: () => ({ send: done, json: done, end: done }),',
|
|
419
|
+
' };',
|
|
420
|
+
` const req = { ${src.prop}: ${JSON.stringify({ [src.key]: payload })} };`,
|
|
421
|
+
' timer = setTimeout(resolve, 3000);', // armed before the call, see the webhook template
|
|
422
|
+
` try { ${invoke}(req, res); } catch { done(); }`,
|
|
423
|
+
'});',
|
|
424
|
+
'',
|
|
425
|
+
'// The whole decision, stated once: sentinel inside a SQL string is the bug;',
|
|
426
|
+
'// sentinel inside a params array is the fix.',
|
|
427
|
+
'let proven = false;',
|
|
428
|
+
'try {',
|
|
429
|
+
` for (const line of fs.readFileSync(${JSON.stringify(SQL_LOG)}, 'utf8').split('\\n')) {`,
|
|
430
|
+
' if (!line.trim()) continue;',
|
|
431
|
+
' const rec = JSON.parse(line);',
|
|
432
|
+
' for (const s of rec.strings) {',
|
|
433
|
+
` if (s.includes(${JSON.stringify(SQL_SENTINEL)}) && /\\b(?:select|insert|update|delete|from|where)\\b/i.test(s)) proven = true;`,
|
|
434
|
+
' }',
|
|
435
|
+
' }',
|
|
436
|
+
'} catch {}',
|
|
437
|
+
`if (proven) fs.writeFileSync('${MARKER}', 'x');`,
|
|
438
|
+
].join('\n');
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
poc: {
|
|
443
|
+
lang: 'js', kind: 'in-process', family: finding.family, cwe: finding.cwe || null,
|
|
444
|
+
marker: MARKER, paramKey: src.key, paramSource: src.prop, handler: handlerLabel,
|
|
445
|
+
driver,
|
|
446
|
+
observes: 'the request payload reached the database driver inside the SQL text rather than as a bound parameter',
|
|
447
|
+
requires: [base],
|
|
448
|
+
// The stub replaces the real package. Resolution finds `index.js` in a
|
|
449
|
+
// package directory with no package.json, which is why no manifest is
|
|
450
|
+
// written — one more file that could disagree with itself.
|
|
451
|
+
extraFiles: { [`node_modules/${driver}/index.js`]: DRIVER_STUB },
|
|
452
|
+
code,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
// ── Path traversal, proven by reading a file outside the served directory ───
|
|
459
|
+
//
|
|
460
|
+
// The proof is a specific observable fact: content the handler had no business
|
|
461
|
+
// serving came back out of it. The PoC plants a sentinel file at the sandbox
|
|
462
|
+
// root, asks the handler for it by a path that has to climb out of the served
|
|
463
|
+
// directory to reach it, and writes the marker only if the sentinel's own
|
|
464
|
+
// content (or, for `sendFile`, the sentinel's resolved path) comes back.
|
|
465
|
+
//
|
|
466
|
+
// NO SOURCE-INSPECTION REFUSAL HERE, unlike the webhook class. There, a file
|
|
467
|
+
// mentioning signature verification left a question execution could not settle,
|
|
468
|
+
// so the template refused. Here execution settles it completely: a handler that
|
|
469
|
+
// normalises the path returns nothing, and that is a real `proof-failed` about
|
|
470
|
+
// the finding rather than a harness artefact. The template only refuses shapes
|
|
471
|
+
// whose FAILURE would be about the harness.
|
|
472
|
+
const TRAVERSAL_SENTINEL = 'PROVEN_TRAVERSAL_SENTINEL_CONTENT';
|
|
473
|
+
const READ_SINK = /\b(?:readFile|readFileSync|sendFile|readFileAsync)\s*\(/;
|
|
474
|
+
|
|
475
|
+
function _pathTraversalPoc(finding, fileContent) {
|
|
476
|
+
const found = _findHandler(fileContent);
|
|
477
|
+
if (!found) return NO_HANDLER;
|
|
478
|
+
const { call, reqIdent } = found;
|
|
479
|
+
|
|
480
|
+
// Checked BEFORE the read-sink refusal: a streamed handler reads its file
|
|
481
|
+
// through `createReadStream`, so the generic "no read sink" message would be
|
|
482
|
+
// both wrong and the one the reader sees.
|
|
483
|
+
//
|
|
484
|
+
// A streamed response is written to a real socket. The fake response object
|
|
485
|
+
// cannot be piped into, so the PoC would fail for harness reasons — which
|
|
486
|
+
// would be recorded as `proof-failed`, a claim about the finding.
|
|
487
|
+
if (/\.pipe\s*\(|createReadStream\s*\(/.test(fileContent)) {
|
|
488
|
+
return { ok: false, reason: 'the response is streamed; a failure against the in-process response object would be the harness talking, not the finding' };
|
|
489
|
+
}
|
|
490
|
+
if (!READ_SINK.test(fileContent)) {
|
|
491
|
+
return { ok: false, reason: 'no readFile/sendFile sink in the file, so there is no served content to observe coming back' };
|
|
492
|
+
}
|
|
493
|
+
const src = _requestSource(fileContent, reqIdent);
|
|
494
|
+
if (!src) {
|
|
495
|
+
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the traversal point is unknown` };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const { base, importLine, invoke, handlerLabel } = _binding(finding, call);
|
|
499
|
+
|
|
500
|
+
const code = [
|
|
501
|
+
`// Auto-generated proof for ${finding.file}.`,
|
|
502
|
+
'// Plants a sentinel file outside the served directory and asks the handler',
|
|
503
|
+
`// for it. The marker '${MARKER}' is written only if the sentinel's own`,
|
|
504
|
+
'// content — or the path it resolves to — comes back out of the handler.',
|
|
505
|
+
importLine,
|
|
506
|
+
"import fs from 'node:fs';",
|
|
507
|
+
"import path from 'node:path';",
|
|
508
|
+
`const SENTINEL = ${JSON.stringify(TRAVERSAL_SENTINEL)};`,
|
|
509
|
+
"const file = 'traversal-sentinel.txt';",
|
|
510
|
+
'fs.writeFileSync(file, SENTINEL);',
|
|
511
|
+
'const target = path.resolve(file);',
|
|
512
|
+
'',
|
|
513
|
+
'// Both shapes a vulnerable handler falls to: an absolute path (string',
|
|
514
|
+
"// concatenation, or a read with no join at all) and a climb out of the",
|
|
515
|
+
'// served directory. The depth is unknown, so a few are tried; every one of',
|
|
516
|
+
'// them resolves inside the sandbox root or fails harmlessly.',
|
|
517
|
+
'const candidates = [target];',
|
|
518
|
+
"for (let i = 1; i <= 6; i++) candidates.push('../'.repeat(i) + file);",
|
|
519
|
+
'',
|
|
520
|
+
'let proven = false;',
|
|
521
|
+
'for (const candidate of candidates) {',
|
|
522
|
+
' if (proven) break;',
|
|
523
|
+
' const seen = [];',
|
|
524
|
+
' await new Promise((resolve) => {',
|
|
525
|
+
' let timer = null;',
|
|
526
|
+
' const done = () => { clearTimeout(timer); resolve(); };',
|
|
527
|
+
' const capture = (v) => { if (v !== undefined && v !== null) seen.push(v); done(); };',
|
|
528
|
+
' const res = {',
|
|
529
|
+
' send: capture, json: capture, end: capture, write: capture,',
|
|
530
|
+
' sendFile: capture, download: capture,',
|
|
531
|
+
' status: () => ({ send: capture, json: capture, end: capture }),',
|
|
532
|
+
' setHeader: () => {}, type: () => res, set: () => res,',
|
|
533
|
+
' };',
|
|
534
|
+
` const req = { ${src.prop}: { ${JSON.stringify(src.key)}: candidate } };`,
|
|
535
|
+
' timer = setTimeout(resolve, 3000);', // armed before the call
|
|
536
|
+
` try { ${invoke}(req, res); } catch { done(); }`,
|
|
537
|
+
' });',
|
|
538
|
+
' for (const v of seen) {',
|
|
539
|
+
" const s = Buffer.isBuffer(v) ? v.toString('utf8') : typeof v === 'string' ? v : JSON.stringify(v) || '';",
|
|
540
|
+
' // Content coming back is the direct proof.',
|
|
541
|
+
' if (s.includes(SENTINEL)) { proven = true; break; }',
|
|
542
|
+
' // `sendFile` hands back a path, not content: the handler resolving the',
|
|
543
|
+
' // request to the sentinel OUTSIDE its directory is the same fact.',
|
|
544
|
+
' try { if (s && path.resolve(s) === target) { proven = true; break; } } catch {}',
|
|
545
|
+
' }',
|
|
546
|
+
'}',
|
|
547
|
+
`if (proven) fs.writeFileSync('${MARKER}', 'x');`,
|
|
548
|
+
].join('\n');
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
ok: true,
|
|
552
|
+
poc: {
|
|
553
|
+
lang: 'js', kind: 'in-process', family: finding.family, cwe: finding.cwe || null,
|
|
554
|
+
marker: MARKER, paramKey: src.key, paramSource: src.prop, handler: handlerLabel,
|
|
555
|
+
observes: 'the handler returned a file from outside the directory it serves',
|
|
556
|
+
requires: [base], code,
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
// Exported last: the template constants below are `const` in TDZ until their
|
|
563
|
+
// declarations are evaluated.
|
|
564
|
+
export const _internals = {
|
|
565
|
+
MARKER, SUPPORTED, _requestSource, SQL_DRIVERS, SQL_SENTINEL, TRAVERSAL_SENTINEL,
|
|
566
|
+
behaviouralFamilies: () => [...BEHAVIOURAL.keys()],
|
|
567
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Proof-artifact provenance (PRD Epic 1.4 + 7.4).
|
|
2
|
+
//
|
|
3
|
+
// An `execution-proven` finding is only worth more than a pattern match if a
|
|
4
|
+
// reader can tell WHAT was proven and check that the claim was not edited after
|
|
5
|
+
// the fact. This module turns the evidence a proof run produced into two things
|
|
6
|
+
// a report can carry:
|
|
7
|
+
//
|
|
8
|
+
// 1. `proofLevel` — the PRD's public vocabulary, mapped from the engine's
|
|
9
|
+
// internal `proofTier`. The engine's names are about analysis provenance;
|
|
10
|
+
// the PRD's are about what a reader may conclude. They are deliberately
|
|
11
|
+
// NOT the same strings, and mapping in one place stops the two vocabularies
|
|
12
|
+
// drifting into each other across reporters.
|
|
13
|
+
//
|
|
14
|
+
// 2. `proofArtifactSha256` — a digest over the evidence that actually
|
|
15
|
+
// justified the tier: the PoC that ran, the effect observed, the backend it
|
|
16
|
+
// ran on, and the tier claimed. A fix PR can then reference the digest, and
|
|
17
|
+
// anyone re-running the proof can check they are looking at the same
|
|
18
|
+
// artifact rather than a later, friendlier one.
|
|
19
|
+
//
|
|
20
|
+
// WHAT THE DIGEST IS AND IS NOT. It is tamper-EVIDENCE over the proof record,
|
|
21
|
+
// not proof the exploit is real — the execution is what does that. It is also
|
|
22
|
+
// not a signature: it commits to content, and anyone can recompute it. Signing
|
|
23
|
+
// belongs with `integrity.js`, which already has a key and a provenance story;
|
|
24
|
+
// duplicating that here would be a second crypto path for no gain.
|
|
25
|
+
//
|
|
26
|
+
// Timestamps are deliberately excluded. Two runs that proved the same thing the
|
|
27
|
+
// same way should produce the same digest, or the field cannot be used to say
|
|
28
|
+
// "this is the artifact the PR was reviewed against".
|
|
29
|
+
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
// Engine tier -> PRD proof level. Every tier maps; an unknown tier maps to the
|
|
33
|
+
// weakest level rather than being dropped, because a missing level would read
|
|
34
|
+
// as "not applicable" instead of "we do not know".
|
|
35
|
+
const TIER_TO_LEVEL = Object.freeze({
|
|
36
|
+
'execution-proven': 'PROVEN',
|
|
37
|
+
'proof-failed': 'PROBABLE_FP',
|
|
38
|
+
'taint-proven': 'REACHABLE',
|
|
39
|
+
'unproven': 'PATTERN',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const PROOF_LEVELS = Object.freeze(['PROVEN', 'PROBABLE_FP', 'REACHABLE', 'PATTERN']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The PRD-facing proof level for a finding.
|
|
46
|
+
*
|
|
47
|
+
* Returns null when the finding carries no tier at all — a scan that never ran
|
|
48
|
+
* the proof stage must not have every finding labelled `PATTERN`, which would
|
|
49
|
+
* assert that each was considered and found unprovable.
|
|
50
|
+
*/
|
|
51
|
+
export function proofLevelOf(finding) {
|
|
52
|
+
const tier = finding?.proofTier;
|
|
53
|
+
if (!tier) return null;
|
|
54
|
+
return TIER_TO_LEVEL[tier] || 'PATTERN';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Digest over the evidence that justified the tier. Null when there is no
|
|
59
|
+
* evidence to commit to — an absent hash is honest; a hash over nothing is not.
|
|
60
|
+
*/
|
|
61
|
+
export function proofArtifactDigest(finding) {
|
|
62
|
+
const ev = finding?.proofEvidence;
|
|
63
|
+
if (!ev || !finding?.proofTier) return null;
|
|
64
|
+
// Only fields that constitute the CLAIM. `at` (a timestamp) and `exitCode`
|
|
65
|
+
// are excluded: they vary between identical proofs and would make the digest
|
|
66
|
+
// useless for "same artifact?" comparisons.
|
|
67
|
+
const material = JSON.stringify({
|
|
68
|
+
tier: finding.proofTier,
|
|
69
|
+
ran: ev.ran === true,
|
|
70
|
+
backend: ev.backend ?? null,
|
|
71
|
+
observed: ev.observed ?? null,
|
|
72
|
+
reason: ev.reason ?? null,
|
|
73
|
+
marker: finding.poc?.marker ?? null,
|
|
74
|
+
poc: finding.poc?.code ?? null,
|
|
75
|
+
});
|
|
76
|
+
return crypto.createHash('sha256').update(material).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The reportable proof block, or null when the finding has no proof standing.
|
|
81
|
+
* One shape, so every reporter says the same thing the same way.
|
|
82
|
+
*/
|
|
83
|
+
export function proofBlock(finding) {
|
|
84
|
+
const level = proofLevelOf(finding);
|
|
85
|
+
if (!level) return null;
|
|
86
|
+
const ev = finding?.proofEvidence || {};
|
|
87
|
+
return {
|
|
88
|
+
proofLevel: level,
|
|
89
|
+
proofTier: finding.proofTier,
|
|
90
|
+
proofRan: ev.ran === true,
|
|
91
|
+
...(ev.backend ? { proofBackend: ev.backend } : {}),
|
|
92
|
+
...(ev.observed ? { proofObserved: ev.observed } : {}),
|
|
93
|
+
// Carried on the weaker levels too: "the PoC ran and nothing happened" is a
|
|
94
|
+
// different statement from "no PoC was attempted", and the reason is what
|
|
95
|
+
// distinguishes them.
|
|
96
|
+
...(ev.reason ? { proofReason: ev.reason } : {}),
|
|
97
|
+
...(proofArtifactDigest(finding) ? { proofArtifactSha256: proofArtifactDigest(finding) } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const _internals = { TIER_TO_LEVEL };
|