@clear-capabilities/agentic-security-scanner 0.133.0 → 0.136.2

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 (111) hide show
  1. package/CHANGELOG.md +299 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +348 -40
  5. package/dist/113.index.js +6 -6
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/220.index.js +5 -3
  9. package/dist/238.index.js +4 -4
  10. package/dist/317.index.js +270 -0
  11. package/dist/384.index.js +1 -1
  12. package/dist/435.index.js +13 -7
  13. package/dist/499.index.js +86 -0
  14. package/dist/513.index.js +7 -3
  15. package/dist/526.index.js +6 -6
  16. package/dist/609.index.js +741 -0
  17. package/dist/637.index.js +1 -1
  18. package/dist/675.index.js +7 -5
  19. package/dist/839.index.js +4 -3
  20. package/dist/905.index.js +1173 -0
  21. package/dist/agentic-security.mjs +56 -56
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/dist/compliance-frameworks/ccpa.json +32 -0
  24. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  25. package/dist/compliance-frameworks/gdpr.json +45 -0
  26. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  27. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  28. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  29. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  30. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  31. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  32. package/package.json +14 -6
  33. package/src/badge.js +2 -1
  34. package/src/dataflow/cross-service-taint.js +2 -1
  35. package/src/dataflow/ifds-precise.js +6 -4
  36. package/src/dataflow/incremental.js +7 -5
  37. package/src/discovery/CLAUDE.md +48 -0
  38. package/src/discovery/confirm.js +47 -0
  39. package/src/discovery/disprove.js +79 -0
  40. package/src/discovery/hunter.js +116 -0
  41. package/src/discovery/index.js +331 -0
  42. package/src/discovery/judge.js +97 -0
  43. package/src/discovery/lenses.js +69 -0
  44. package/src/discovery/llm-invoke.js +120 -0
  45. package/src/discovery/memory.js +163 -0
  46. package/src/discovery/partition.js +92 -0
  47. package/src/engine.js +163 -7
  48. package/src/integrations/tickets.js +7 -6
  49. package/src/ir/ir-stats.js +1 -1
  50. package/src/ir/parser-cpp.js +1 -1
  51. package/src/leaderboard.js +3 -2
  52. package/src/llm-validator/consistency.js +6 -2
  53. package/src/llm-validator/index.js +30 -41
  54. package/src/llm-validator/providers.js +227 -0
  55. package/src/mcp/audit.js +2 -1
  56. package/src/mcp/tools.js +4 -3
  57. package/src/posture/CLAUDE.md +102 -1
  58. package/src/posture/accuracy-scorecard.js +59 -1
  59. package/src/posture/agents-memory.js +5 -3
  60. package/src/posture/auditor-walkthrough.js +9 -7
  61. package/src/posture/auth-posture-import.js +5 -4
  62. package/src/posture/autopilot.js +225 -0
  63. package/src/posture/comparison.js +181 -0
  64. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  65. package/src/posture/compliance-policy.js +7 -9
  66. package/src/posture/custom-rules.js +7 -5
  67. package/src/posture/cve-alert-daemon.js +6 -5
  68. package/src/posture/dep-add-guard.js +2 -1
  69. package/src/posture/deterministic.js +3 -2
  70. package/src/posture/evidence-bundle.js +246 -0
  71. package/src/posture/execution-proof.js +25 -1
  72. package/src/posture/exploitability-probability.js +2 -1
  73. package/src/posture/feature-flags.js +3 -2
  74. package/src/posture/findings-memory.js +3 -3
  75. package/src/posture/fix-history.js +5 -2
  76. package/src/posture/fix-metrics.js +5 -5
  77. package/src/posture/fix-plan.js +2 -1
  78. package/src/posture/fleet.js +0 -0
  79. package/src/posture/grader-calibration.js +3 -4
  80. package/src/posture/intent-context.js +2 -1
  81. package/src/posture/learning.js +4 -3
  82. package/src/posture/license-attributions.js +5 -7
  83. package/src/posture/license-graph.js +2 -1
  84. package/src/posture/license-policy.js +2 -1
  85. package/src/posture/logic-claims.js +266 -0
  86. package/src/posture/model-rescan.js +4 -3
  87. package/src/posture/network-policy-import.js +3 -2
  88. package/src/posture/poc-inprocess.js +404 -2
  89. package/src/posture/pqc-migration-plan.js +7 -5
  90. package/src/posture/pr-augment.js +8 -5
  91. package/src/posture/privacy-framework.js +262 -0
  92. package/src/posture/proof-artifact.js +101 -0
  93. package/src/posture/prove-findings.js +28 -4
  94. package/src/posture/risk-dollars.js +2 -2
  95. package/src/posture/router.js +5 -4
  96. package/src/posture/ruleset-version.js +2 -2
  97. package/src/posture/runtime-correlation.js +2 -1
  98. package/src/posture/sbom-diff.js +12 -3
  99. package/src/posture/sca-policy.js +7 -4
  100. package/src/posture/scan-checkpoint.js +15 -0
  101. package/src/posture/state-dir.js +34 -0
  102. package/src/posture/telemetry-ingest.js +4 -3
  103. package/src/posture/threat-model-auto.js +4 -1
  104. package/src/posture/threat-model-grounding.js +11 -1
  105. package/src/posture/time-to-fix.js +3 -2
  106. package/src/posture/triage-memory.js +3 -2
  107. package/src/posture/waf-ingest.js +6 -5
  108. package/src/posture/watch-mode.js +4 -3
  109. package/src/report/index.js +9 -0
  110. package/src/sast/code-injection-multilang.js +29 -0
  111. package/src/sca/dep-confusion.js +2 -1
@@ -30,7 +30,44 @@ const MARKER = 'PROVEN';
30
30
  // Only families where "the injected payload ran" is observable from a marker
31
31
  // file. Others (XSS, weak crypto) need a browser or a judgement call about
32
32
  // output, and a marker-file proof would be a category error.
33
- const SUPPORTED = new Set(['command-injection', 'code-injection']);
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.
34
71
 
35
72
  const JS_EXT = /\.(js|cjs|mjs)$/i;
36
73
 
@@ -48,6 +85,37 @@ const NAMED_HANDLER_RES = [
48
85
  /(?:module\.)?exports\.(\w+)\s*=\s*(?:async\s+)?\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>/,
49
86
  ];
50
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
+
51
119
  // The request property the handler reads. Anchored to the request identifier
52
120
  // the export actually binds, so a file that reads `req.query` while exporting
53
121
  // `(request, response)` does not produce a PoC built on the wrong name.
@@ -88,6 +156,9 @@ export function synthesizeInProcessPoc(finding, fileContent) {
88
156
  return { ok: false, reason: 'ES-module source: the CommonJS handler shapes do not apply' };
89
157
  }
90
158
 
159
+ const behavioural = BEHAVIOURAL.get(finding.family);
160
+ if (behavioural) return behavioural(finding, fileContent);
161
+
91
162
  if (!SHELL_SINK.test(fileContent)) {
92
163
  return {
93
164
  ok: false,
@@ -162,4 +233,335 @@ export function synthesizeInProcessPoc(finding, fileContent) {
162
233
  };
163
234
  }
164
235
 
165
- export const _internals = { MARKER, SUPPORTED, _requestSource };
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
+ };
@@ -18,8 +18,7 @@
18
18
  // suggestions; JSON-LD-shaped structured output is consumable by Vanta /
19
19
  // Drata / SecureFrame or any custom rollup dashboard.
20
20
 
21
- import * as fs from 'node:fs';
22
- import * as path from 'node:path';
21
+ import { statePath, safeWriteState } from './state-dir.js';
23
22
 
24
23
  function _byHndl(findings) {
25
24
  return {
@@ -117,9 +116,12 @@ export function buildMigrationPlan(allFindings) {
117
116
 
118
117
  export function persistMigrationPlan(scanRoot, plan) {
119
118
  if (!plan) return null;
120
- try { fs.mkdirSync(path.join(scanRoot, '.agentic-security'), { recursive: true }); } catch {}
121
- try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'pqc-migration-plan.json'), JSON.stringify(plan, null, 2)); } catch {}
122
- try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'pqc-migration-plan.md'), _markdown(plan)); } catch {}
119
+ // Through the seam: creates the directory, enforces the project-root check,
120
+ // and honours the read-only scan switch. The plan is still RETURNED when
121
+ // writing is off a scan must report the same result whether or not it is
122
+ // allowed to leave files behind.
123
+ safeWriteState(statePath(scanRoot, 'pqc-migration-plan.json'), JSON.stringify(plan, null, 2));
124
+ safeWriteState(statePath(scanRoot, 'pqc-migration-plan.md'), _markdown(plan));
123
125
  return plan;
124
126
  }
125
127
 
@@ -18,6 +18,7 @@ import * as fs from 'node:fs';
18
18
  import * as path from 'node:path';
19
19
  import { diffScans, summarizeDiff } from './baseline-compare.js';
20
20
 
21
+ import { statePath, safeWriteState } from './state-dir.js';
21
22
  const REVIEWER_TRIGGERS = [
22
23
  { family: /^auth/, team: 'security', why: 'Auth-related findings' },
23
24
  { family: /^crypto/, team: 'security', why: 'Cryptography findings' },
@@ -29,7 +30,7 @@ const REVIEWER_TRIGGERS = [
29
30
  ];
30
31
 
31
32
  function _stateFile(scanRoot, name) {
32
- return path.join(scanRoot, '.agentic-security', name);
33
+ return statePath(scanRoot, name);
33
34
  }
34
35
 
35
36
  function _readJson(fp) {
@@ -38,7 +39,7 @@ function _readJson(fp) {
38
39
 
39
40
  function _baselinePath(scanRoot, ref) {
40
41
  const safe = String(ref || 'main').replace(/[^\w.-]/g, '-');
41
- return path.join(scanRoot, '.agentic-security', 'scan-baselines', `${safe}.json`);
42
+ return statePath(scanRoot, 'scan-baselines', `${safe}.json`);
42
43
  }
43
44
 
44
45
  /**
@@ -47,8 +48,10 @@ function _baselinePath(scanRoot, ref) {
47
48
  */
48
49
  export function persistBaseline(scanRoot, ref, scan) {
49
50
  const fp = _baselinePath(scanRoot, ref);
50
- try { fs.mkdirSync(path.dirname(fp), { recursive: true }); } catch {}
51
- try { fs.writeFileSync(fp, JSON.stringify({ ref, ts: new Date().toISOString(), findings: scan.findings || [] }, null, 2)); } catch {}
51
+ // safeWriteState creates the directory, enforces the project-root check, and
52
+ // honours the read-only switch. The path is still returned either way, so
53
+ // callers that only report where the baseline WOULD live are unaffected.
54
+ safeWriteState(fp, JSON.stringify({ ref, ts: new Date().toISOString(), findings: scan.findings || [] }, null, 2));
52
55
  return fp;
53
56
  }
54
57
 
@@ -109,7 +112,7 @@ function _artifactLinks(scanRoot) {
109
112
  ];
110
113
  const out = [];
111
114
  for (const c of candidates) {
112
- const fp = path.join(scanRoot, '.agentic-security', c.file);
115
+ const fp = statePath(scanRoot, c.file);
113
116
  if (fs.existsSync(fp)) out.push({ name: c.name, path: `.agentic-security/${c.file}` });
114
117
  }
115
118
  return out;