@clear-capabilities/agentic-security-scanner 0.186.2 → 0.188.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 (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/1602.index.js +275 -19
  3. package/dist/{4d06c8944ae121d7085c.json → 92f78fd3e2dbe30fb58d.json} +5 -5
  4. package/dist/9560.index.js +61 -4
  5. package/dist/agentic-security.mjs +2 -2
  6. package/dist/agentic-security.mjs.sha256 +1 -1
  7. package/package.json +5 -5
  8. package/src/capabilities/decide.js +7 -1
  9. package/src/dataflow/CLAUDE.md +15 -0
  10. package/src/dataflow/catalog-haskell.js +7 -1
  11. package/src/dataflow/catalog.js +14 -0
  12. package/src/dataflow/engine.js +147 -10
  13. package/src/dataflow/privacy-catalog-haskell.js +2 -1
  14. package/src/engine.js +11 -3
  15. package/src/language/CLAUDE.md +26 -0
  16. package/src/language/compliance-map.js +6 -0
  17. package/src/language/context.js +20 -0
  18. package/src/language/haskell-comments.js +65 -0
  19. package/src/language/haskell-disclosure.js +16 -4
  20. package/src/language/haskell-findings.js +2 -1
  21. package/src/language/haskell-fix.js +118 -2
  22. package/src/language/haskell-guards.js +132 -5
  23. package/src/language/haskell-ir.js +228 -13
  24. package/src/language/haskell-models.js +108 -8
  25. package/src/language/haskell-security-rules.js +223 -41
  26. package/src/language/haskell-syntax.js +10 -1
  27. package/src/language/haskell-trust.js +268 -0
  28. package/src/language/haskell-web.js +46 -9
  29. package/src/language/support-registry.js +3 -2
  30. package/src/mcp/tools.js +54 -3
  31. package/src/posture/attack-taxonomy.js +10 -0
  32. package/src/posture/entrypoint-inventory.js +6 -2
  33. package/src/posture/proof-fabric/generate-haskell.js +266 -17
  34. package/src/posture/proof-fabric/generate.js +10 -3
  35. package/src/posture/proof-fabric/oracles.js +21 -0
  36. package/src/posture/stable-id.js +2 -1
  37. package/src/report/index.js +3 -0
  38. package/src/sast/cloud-iam.js +3 -1
  39. package/dist/2066.index.js +0 -851
@@ -21,9 +21,19 @@
21
21
  // or any Hackage package is refused with the package named where it is known. Template Haskell, quasi-quotes, CPP, the foreign
22
22
  // function interface and compiler-plugin options are refused too: they run code at load time, before the payload is involved.
23
23
  //
24
- // Classes: command injection (CWE-78) and path traversal by a write (CWE-22). SSRF (CWE-918) is refused for Haskell on purpose: no
25
- // HTTP client ships with GHC, so a request-making program necessarily imports a package the stdlib-only rule already refuses, and
26
- // nothing could be proven honestly. Nothing is executed on a refusal.
24
+ // Classes, each with an oracle that sees its effect and nothing else:
25
+ // command injection (CWE-78) a nonce-named canary file the injected command alone could create;
26
+ // path traversal by a WRITE a file created or changed outside the directory the program writes to (CWE-22);
27
+ // path traversal by a READ a per-run secret, planted in a file one directory above the program's base directory, appears in the
28
+ // program's output (stdout, stderr or, for a function, its printed result). A read whose content never
29
+ // reaches the output leaves nothing to see and stays unproven;
30
+ // argument injection (CWE-88) the called program is replaced by a harness stand-in that acts on one planted option, but only when that
31
+ // option is its FIRST argument; the canary exists only if attacker data reached the first position of the
32
+ // argument vector. It shows the option position is attacker-controlled, not that the real program has a
33
+ // dangerous option, and the generated reproducer says so in its assumptions.
34
+ // Refused on purpose, with the construct named and the way to supply a hand-written reproducer: SSRF (no HTTP client ships with GHC), SQL
35
+ // (no database binding ships with GHC), deserialization, XSS, code injection, argument injection past the first argument, reads and writes
36
+ // whose base directory the file does not state, and any function the proof cannot fill or call. Nothing is executed on a refusal.
27
37
  import fs from 'node:fs';
28
38
  import path from 'node:path';
29
39
  import { spawnSync } from 'node:child_process';
@@ -108,20 +118,189 @@ function classify(mod, pkg, boot) {
108
118
  return { ok: false, name: mod };
109
119
  }
110
120
 
121
+ // ---------------------------------------------------------------------------------------------------------------- parameter types
122
+
123
+ const STRING_TYPES = /^(?:String|Prelude\.String|FilePath|System\.IO\.FilePath|\[Char\])$/;
124
+ const unparen = (t) => { let x = t.trim(); while (x.startsWith('(') && x.endsWith(')') && splitArrows(x.slice(1, -1)).length === 1 && !x.slice(1, -1).includes(',')) x = x.slice(1, -1).trim(); return x; };
125
+
126
+ /** What the file means by `Text` and `ByteString` (an import of the real module, not a local type of the same name) and its one-line `type` aliases. */
127
+ function typeResolver(info, text) {
128
+ const textQ = new Set(); const bytesQ = new Set(); let textBare = false; let bytesBare = false;
129
+ for (const imp of info.imports ?? []) {
130
+ const isText = /^Data\.Text(?:\.Lazy)?$/.test(imp.module);
131
+ const isBytes = /^Data\.ByteString(?:\.(?:Char8|Lazy|Lazy\.Char8))?$/.test(imp.module);
132
+ if (!isText && !isBytes) continue;
133
+ const bare = !imp.qualified && !imp.hiding && (imp.items == null || imp.items.some((i) => /^(?:Text|ByteString)\b/.test(i)));
134
+ if (isText) { textQ.add(imp.as ?? imp.module); textBare ||= bare; } else { bytesQ.add(imp.as ?? imp.module); bytesBare ||= bare; }
135
+ }
136
+ const localDef = (n) => new RegExp(`^(?:data|newtype)\\s+${n}\\b`, 'm').test(text);
137
+ if (localDef('Text')) textBare = false;
138
+ if (localDef('ByteString')) bytesBare = false;
139
+ const aliases = new Map();
140
+ for (const m of text.matchAll(/^type\s+([A-Z][\w']*)\s*=\s*([^\n=]+?)\s*(?:--.*)?$/gm)) aliases.set(m[1], m[2]);
141
+ const resolve = (t, depth = 0) => {
142
+ const x = unparen(t);
143
+ if (depth < 4 && aliases.has(x)) return resolve(aliases.get(x), depth + 1);
144
+ return x;
145
+ };
146
+ const stringish = (t) => {
147
+ const x = resolve(t);
148
+ if (STRING_TYPES.test(x)) return 'string';
149
+ const q = /^(?:([A-Z][\w.]*)\.)?(Text|ByteString)$/.exec(x);
150
+ if (!q) return null;
151
+ if (q[2] === 'Text') return q[1] ? (textQ.has(q[1]) ? 'overloaded' : null) : (textBare ? 'overloaded' : null);
152
+ return q[1] ? (bytesQ.has(q[1]) ? 'overloaded' : null) : (bytesBare ? 'overloaded' : null);
153
+ };
154
+ /** `{lit}` is the Haskell expression for a value of this type holding `value`, `{needsOverloaded}` whether it relies on OverloadedStrings. */
155
+ const fill = (t, value) => {
156
+ const x = resolve(t);
157
+ const one = stringish(x);
158
+ if (one) return { expr: value, overloaded: one === 'overloaded' };
159
+ const list = /^\[\s*(.+?)\s*\]$/.exec(x);
160
+ if (list) { const e = stringish(list[1]); if (e) return { expr: `[${value}]`, overloaded: e === 'overloaded' }; }
161
+ const maybe = /^Maybe\s+(.+)$/.exec(x);
162
+ if (maybe) { const e = stringish(maybe[1]); if (e) return { expr: `(Just ${value})`, overloaded: e === 'overloaded' }; }
163
+ return null;
164
+ };
165
+ /** The result can be shown by `print` and carries the content a read returned (String, Text, ByteString, lists and Maybe of them). */
166
+ const printable = (t) => {
167
+ const x = resolve(t);
168
+ return !!(stringish(x) || (/^\[\s*(.+?)\s*\]$/.exec(x) && stringish(/^\[\s*(.+?)\s*\]$/.exec(x)[1])) || (/^Maybe\s+(.+)$/.exec(x) && stringish(/^Maybe\s+(.+)$/.exec(x)[1])));
169
+ };
170
+ return { fill, printable, resolve };
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------------------------------------------- path traversal: write or read
174
+
175
+ const READ_SINK = /\b(?:readFile'?|readFileBinary)\b|\b(?:openFile|withFile|openBinaryFile|withBinaryFile)\b[^\n]*\bReadMode\b/;
176
+ const READ_PAYLOAD = '../agentic-proof-secret.txt';
177
+ const READ_SECRET_FILE = 'agentic-proof-secret.txt';
178
+ const READ_ORACLE = { type: 'file_disclosure', params: { secret: 'file' } };
179
+ const WRITE_CONSTRUCTS = 'writeFile, appendFile, copyFile, renameFile, or openFile/withFile in WriteMode/AppendMode';
180
+ const READ_CONSTRUCTS = 'readFile, or openFile/withFile in ReadMode';
181
+
182
+ /** Which path operation the flagged line is: its own line first, then the lines around it. */
183
+ function pathKind(sinkLine, around, writeRe) {
184
+ if (writeRe.test(sinkLine)) return 'write';
185
+ if (READ_SINK.test(sinkLine)) return 'read';
186
+ if (writeRe.test(around)) return 'write';
187
+ if (READ_SINK.test(around)) return 'read';
188
+ return null;
189
+ }
190
+
191
+ /**
192
+ * The directories a read is meant to stay under, as far as the source says: a literal that ends in a slash, or sits next to `</>`, or is
193
+ * bound to a name that says directory. The proof creates them (empty) so that `dir/../secret` resolves; a program whose base is not a
194
+ * relative literal cannot be modelled and is refused rather than guessed.
195
+ */
196
+ function readBaseDirs(text) {
197
+ const found = [];
198
+ const add = (lit) => {
199
+ if (!lit || lit.startsWith('/') || lit.includes('..') || lit.includes('\\') || /[{}\s:*?"<>|]/.test(lit)) return;
200
+ const seg = lit.split('/').filter(Boolean)[0];
201
+ if (!seg || seg === '.' || ['canary', 'tmp'].includes(seg) || !/^[A-Za-z0-9._-]{1,32}$/.test(seg) || seg.startsWith('__oracle_')) return;
202
+ if (!found.includes(seg)) found.push(seg);
203
+ };
204
+ for (const m of text.matchAll(/"([^"\\\n]+\/)"/g)) add(m[1]);
205
+ for (const m of text.matchAll(/"([^"\\\n]+)"\s*<\/>/g)) add(m[1]);
206
+ for (const m of text.matchAll(/<\/>\s*"([^"\\\n]+)"/g)) add(m[1]);
207
+ for (const m of text.matchAll(/\b[a-z]\w*(?:Dir|Base|Root|Folder|Path|Directory)\w*'?\s*=\s*"([^"\\\n]+)"/gi)) add(m[1]);
208
+ return found.slice(0, 4);
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------------------------------------------- argument injection
212
+
213
+ // Shells and the tools the proof itself runs are never stood in for: an argument to a shell is command injection (CWE-78), and shadowing
214
+ // a toolchain program on PATH would break the proof rather than test the finding.
215
+ const NEVER_STAND_IN = new Set(['sh', 'bash', 'dash', 'zsh', 'csh', 'tcsh', 'ksh', 'fish', 'env', 'ghc', 'runghc', 'ghci', 'cc', 'gcc', 'clang', 'c++', 'g++', 'ld', 'as', 'ar', 'make', 'cabal', 'stack', 'nix', 'sudo', 'su', 'ssh', 'xargs', 'chmod', 'touch', 'node', 'python', 'python3']);
216
+ const PROCESS_CALL = /\b(callProcess|readProcess|readProcessWithExitCode|spawnProcess|rawSystem|proc)\s+(?:\$\s*)?"([^"\\\s]+)"\s*/;
217
+ const STAND_IN = `#!/bin/sh
218
+ # Stand-in for the program the code under test calls. It is planted by the proof runner, never by the target.
219
+ # It acts on exactly one option, and only when that option is its FIRST argument, by creating the file the option names.
220
+ case "$1" in
221
+ --agentic-proof-write=*) : > "\${1#--agentic-proof-write=}" ;;
222
+ esac
223
+ exit 0
224
+ `;
225
+ // PATH puts the stand-in directory first and keeps the system and toolchain directories after it
226
+ const STAND_IN_PATH = '{{ROOT}}/bin:/opt/ghc/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/homebrew/bin';
227
+
228
+ /** What stands in first position of the argument vector at the call: a named element, a whole list, a fixed string, or something else. */
229
+ function firstArgument(rest) {
230
+ if (rest.startsWith('[')) {
231
+ const inner = rest.slice(1).trimStart();
232
+ if (inner.startsWith('"')) return { kind: 'literal' };
233
+ if (inner.startsWith(']')) return { kind: 'empty' };
234
+ const m = /^([a-z_][\w']*)\s*(?:,|\])/.exec(inner);
235
+ return m ? { kind: 'element', name: m[1] } : { kind: 'complex' };
236
+ }
237
+ if (rest.startsWith('(')) {
238
+ const m = /^\(\s*([a-z_][\w']*)\s*:/.exec(rest);
239
+ return m ? { kind: 'element', name: m[1] } : { kind: 'complex' };
240
+ }
241
+ const m = /^([a-z_][\w']*)\s*(?:$|\)|"|--)/.exec(rest);
242
+ return m ? { kind: 'list', name: m[1] } : { kind: 'complex' };
243
+ }
244
+
245
+ const HINT = 'To prove it anyway, write a reproducer for lang "haskell" (run: ["{{GHC}}", "-ignore-dot-ghci", "-e", "<expression that calls the code>", "<File>.hs"], with an oracle such as command_execution, filesystem_mutation or file_disclosure) and pass it with --reproducer <file>; see the Haskell section of the exploit proof guide. Nothing was executed.';
246
+
247
+ /** What the flagged line is, in words, for a refusal message. */
248
+ function describeLine(finding, root) {
249
+ const src = (() => { try { return readInside(root, String(finding.file)); } catch { return null; } })();
250
+ const line = src ? (src.text.split('\n')[finding.line - 1] ?? '').trim().replace(/\s+/g, ' ').slice(0, 90) : '';
251
+ return line ? `the flagged line \`${line}\`` : 'the flagged code';
252
+ }
253
+
254
+ const REFUSALS = {
255
+ 'CWE-89': (what) => `sql-injection: ${what} reaches a database, and GHC ships no database binding, so a program that queries one imports a package outside the proof environment; a program that shells out to the sqlite3 command-line tool depends on a schema, a table and a column count the proof cannot know and a sqlite3 binary this host may not have. A SQL injection is not proven for Haskell.`,
256
+ 'CWE-502': (what) => `deserialization: ${what} decodes data, and nothing observable happens when a decoder reads hostile bytes unless a gadget exists in the program; the decoders in common use (aeson, yaml, cereal) are packages outside GHC's own. A deserialization flaw is not proven for Haskell.`,
257
+ 'CWE-79': (what) => `cross-site scripting: ${what} builds markup, and the effect is in a browser's DOM, which a command-line run cannot observe. XSS is not proven for Haskell.`,
258
+ 'CWE-94': (what) => `code injection: ${what} evaluates code at run time through an interpreter package (hint, plugins) that does not ship with GHC. Code injection is not proven for Haskell.`,
259
+ 'CWE-918': (what) => `ssrf: ${what} makes a request, and no HTTP client ships with GHC, so any program that makes one imports a package outside the proof environment. SSRF is not proven for Haskell.`,
260
+ 'CWE-1427': (what) => `prompt injection: ${what} builds a model prompt, whose effect needs a model endpoint the proof does not have. Not proven for Haskell.`,
261
+ };
262
+
263
+ /**
264
+ * The reason a Haskell finding of a class this generator does not cover is refused, naming the construct and the way out.
265
+ * @param {{finding: object, root: string, cwe: string|null}} o
266
+ */
267
+ export function haskellRefusal({ finding, root, cwe }) {
268
+ const what = describeLine(finding, root);
269
+ const body = REFUSALS[cwe]?.(what) ?? `${cwe ?? 'this class'}: no payload and oracle exist for ${cwe ?? 'it'} in Haskell (${what}). Generated reproducers cover command injection (CWE-78), path traversal by a write or a read (CWE-22) and argument injection into a process call (CWE-88).`;
270
+ return fail(`${body} ${HINT}`);
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------------------------------------------- generation
274
+
111
275
  /** @param {{finding: object, root: string, cls: {name: string, payload: string, oracle: object, network?: string, watch?: object, needsHs?: RegExp}, bootModules?: Set<string>}} o */
112
- export function generateHaskellReproducer({ finding, root, cls, bootModules }) {
276
+ export function generateHaskellReproducer(o) {
277
+ const r = generateInner(o);
278
+ if (r.ok || r.reason.includes('Nothing was executed.')) return r;
279
+ return { ...r, reason: `${r.reason.replace(/\.$/, '')}. ${HINT}` };
280
+ }
281
+
282
+ function generateInner({ finding, root, cls, bootModules }) {
113
283
  const rel = String(finding.file);
114
- if (cls.name === 'ssrf') return fail('ssrf: no HTTP client ships with GHC, so any program that makes a request imports a package outside the proof environment; this class is not proven for Haskell');
284
+ if (cls.name === 'ssrf') return haskellRefusal({ finding, root, cwe: 'CWE-918' });
115
285
  const src = readInside(root, rel);
116
286
  if (!src) return fail('the flagged file is missing, outside the repository, or too large');
117
287
  const hazard = loadTimeHazard(src.text);
118
288
  if (hazard) return fail(`the flagged file is refused: ${hazard}`);
289
+ if (/\{\{[A-Z_]+(?::[a-z0-9_-]{1,32})?\}\}/.test(src.text)) return fail('the flagged file contains text shaped like a proof placeholder ({{NAME}}), which the runner would substitute inside the program');
119
290
  const info = parseHaskell(src.text);
120
291
  if (!info || info.status !== 'parsed') return fail('the flagged file could not be parsed as Haskell');
121
292
  const lines = src.text.split('\n');
122
293
  const sinkLine = lines[finding.line - 1] ?? '';
123
294
  const around = lines.slice(Math.max(0, finding.line - 3), finding.line + 2).join('\n');
124
- if (cls.needsHs && !cls.needsHs.test(sinkLine) && !cls.needsHs.test(around)) return fail(`${cls.name}: the flagged code is not a write, and this class is only proven for write sinks`);
295
+
296
+ // the class decides what is planted and what is watched; for a path operation the flagged line decides which of two it is
297
+ let spec = cls;
298
+ let kind = cls.name;
299
+ if (cls.name === 'path-traversal') {
300
+ const k = pathKind(sinkLine, around, cls.needsHs);
301
+ if (!k) return fail(`path-traversal: ${describeLine(finding, root)} is not a write (${WRITE_CONSTRUCTS}) or a read (${READ_CONSTRUCTS}); other file operations (removeFile, createDirectory, listDirectory, doesFileExist, and the like) leave nothing the oracle can observe`);
302
+ if (k === 'read') { kind = 'path-traversal-read'; spec = { name: 'path-traversal-read', payload: READ_PAYLOAD, oracle: READ_ORACLE }; }
303
+ }
125
304
 
126
305
  const tools = hostTools();
127
306
  const boot = bootModules ?? (tools.ghc ? bootModuleSet(tools.ghc) : null);
@@ -149,6 +328,7 @@ export function generateHaskellReproducer({ finding, root, cls, bootModules }) {
149
328
  const dep = parseHaskell(local.text);
150
329
  if (!dep || dep.status !== 'parsed') return fail(`the module ${mod} could not be parsed as Haskell`);
151
330
  const h = loadTimeHazard(local.text); if (h) return fail(`the module ${mod} is refused: ${h}`);
331
+ if (/\{\{[A-Z_]+(?::[a-z0-9_-]{1,32})?\}\}/.test(local.text)) return fail(`the module ${mod} contains text shaped like a proof placeholder ({{NAME}})`);
152
332
  sources[`${mod.replaceAll('.', '/')}.hs`] = path.relative(rootReal, local.real);
153
333
  const r = visit(dep.imports ?? []); if (r) return r;
154
334
  continue;
@@ -164,18 +344,35 @@ export function generateHaskellReproducer({ finding, root, cls, bootModules }) {
164
344
  sources[entry] = rel;
165
345
 
166
346
  const assumptions = [];
167
- const base = { schema: 'agentic-security/reproducer', version: 1, findingId: String(finding.stableId ?? finding.id ?? 'generated'), lang: 'haskell', oracle: cls.oracle, network: cls.network ?? 'none', ...(cls.watch ? { watch: cls.watch } : {}) };
168
- if (cls.watch) assumptions.push('the write target is assumed to be one directory below the working directory (the payload is ../escaped.txt); if the code uses another base, the proof will not confirm it');
169
- const ghcRun = (expr) => ['{{GHC}}', '-ignore-dot-ghci', '-e', expr, entry];
347
+ const base = { schema: 'agentic-security/reproducer', version: 1, findingId: String(finding.stableId ?? finding.id ?? 'generated'), lang: 'haskell', oracle: spec.oracle, network: spec.network ?? 'none', ...(spec.watch ? { watch: spec.watch } : {}) };
348
+ if (spec.watch) assumptions.push('the write target is assumed to be one directory below the working directory (the payload is ../escaped.txt); if the code uses another base, the proof will not confirm it');
349
+ const ghcRun = (expr, overloaded = false) => ['{{GHC}}', '-ignore-dot-ghci', ...(overloaded ? ['-e', ':set -XOverloadedStrings'] : []), '-e', expr, entry];
170
350
  const literal = (s) => JSON.stringify(s); // payloads are fixed strings with no quote or backslash; this is a plain Haskell string literal
351
+ const resolver = typeResolver(info, src.text);
352
+
353
+ // what the class plants beside the program
354
+ const extras = { files: {}, build: undefined, env: undefined };
355
+ if (kind === 'path-traversal-read') {
356
+ const dirs = readBaseDirs(src.text);
357
+ if (!dirs.length) return fail(`path-traversal: ${describeLine(finding, root)} reads a path whose base directory is not a relative literal in the file (a path built from getCurrentDirectory, an environment variable, an absolute directory or a configuration value), so the proof cannot plant the file it would escape to`);
358
+ extras.files = { [READ_SECRET_FILE]: '{{SECRET:file}}', ...Object.fromEntries(dirs.map((d) => [`${d}/.keep`, ''])) };
359
+ assumptions.push(`the read is assumed to be confined under ${dirs.map((d) => `"${d}"`).join(' or ')}, one directory below the working directory; the proof plants a file one level up (${READ_PAYLOAD}) and sees it only if it reaches the program's output (stdout, stderr or, for a function, its printed result)`);
360
+ }
171
361
 
172
362
  // shape 1: a program that reads its payload from the command line
173
363
  const hasMain = (info.functions ?? []).some((f) => f.scope === 'top' && f.name === 'main');
174
364
  if (/\bgetArgs\b/.test(src.text) && hasMain) {
365
+ if (kind === 'argument-injection') {
366
+ const call = argumentCall(lines, finding);
367
+ if (!call.ok) return call;
368
+ if (call.first.kind !== 'list' || !new RegExp(`\\b${call.first.name}\\s*<-\\s*getArgs\\b`).test(src.text)) return fail(`argument-injection: the first argument of the call to ${call.exe} is ${describeFirst(call.first)}, and the proof can place the payload only where the whole argument vector comes straight from getArgs (\`args <- getArgs\` then the call with \`args\`)`);
369
+ Object.assign(extras, standIn(call.exe));
370
+ assumptions.push(...standInAssumptions(call.exe));
371
+ }
175
372
  assumptions.push('the payload is passed as the first and only command-line argument; a program that expects other arguments will not reach the flagged line');
176
- const reproducer = { ...base, sources, run: ghcRun(`:main ${literal(cls.payload)}`) };
373
+ const reproducer = { ...base, sources, ...(Object.keys(extras.files).length ? { files: extras.files } : {}), ...(extras.build ? { build: extras.build } : {}), ...(extras.env ? { env: extras.env } : {}), run: ghcRun(`:main ${literal(spec.payload)}`) };
177
374
  const errs = validateReproducer(reproducer);
178
- return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'script', class: cls.name, entry: rel, line: finding.line, assumptions } };
375
+ return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'script', class: spec.name, entry: rel, line: finding.line, assumptions } };
179
376
  }
180
377
 
181
378
  // shape 2: a top-level function around the flagged line
@@ -195,11 +392,63 @@ export function generateHaskellReproducer({ finding, root, cls, bootModules }) {
195
392
  const result = parts[parts.length - 1]; const params = parts.slice(0, -1);
196
393
  if (!/^IO\b/.test(result)) return fail(`${fn.name} returns ${result.slice(0, 40)}, not IO, so calling it would not perform the flagged operation`);
197
394
  if (!params.length) return fail(`${fn.name} takes no parameters, so the payload has no way in`);
198
- const bad2 = params.find((p) => !/^(?:String|FilePath|\[Char\])$/.test(p));
199
- if (bad2) return fail(`${fn.name} takes a parameter of type ${bad2.slice(0, 40)}; only String and FilePath can be filled with a payload`);
200
- assumptions.push('the payload is passed in every parameter (a String has no request-like stand-in), which is an approximation of how the function is really called');
201
- const call = `${fn.name} ${params.map(() => literal(cls.payload)).join(' ')} >>= \\_ -> return ()`;
202
- const reproducer = { ...base, sources, run: ghcRun(call) };
395
+ const resultInner = result.replace(/^IO\s*/, '').trim();
396
+ if (kind === 'path-traversal-read' && resultInner !== '()' && !resolver.printable(resultInner)) return fail(`${fn.name} returns IO ${resultInner.slice(0, 40)}, whose content the proof cannot print; a read is observed only in the output, so the result must be (), String, Text, ByteString or a list or Maybe of them`);
397
+
398
+ let payloadFor = () => spec.payload; // which parameters get the payload (all, unless the class says which)
399
+ if (kind === 'argument-injection') {
400
+ const call = argumentCall(lines, finding);
401
+ if (!call.ok) return call;
402
+ const headLine = lines[fn.span.startLine - 1] ?? '';
403
+ const head = new RegExp(`^${fn.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}((?:\\s+[a-z_][\\w']*)*)\\s*(?:=|\\|)`).exec(headLine);
404
+ const names = head ? head[1].trim().split(/\s+/).filter(Boolean) : null;
405
+ if (!names || names.length !== params.length) return fail(`argument-injection: the parameters of ${fn.name} are patterns or the definition is point-free, so the proof cannot tell which parameter reaches the call`);
406
+ if (call.first.kind !== 'element' && call.first.kind !== 'list') return fail(`argument-injection: the first argument of the call to ${call.exe} is ${describeFirst(call.first)}, and the proof can place the payload only where a plain parameter of ${fn.name} stands first in the argument vector`);
407
+ const at = names.indexOf(call.first.name);
408
+ if (at < 0) return fail(`argument-injection: the first argument of the call to ${call.exe} is \`${call.first.name}\`, which is not a parameter of ${fn.name}, so the proof cannot place the payload there`);
409
+ const shape = resolver.fill(params[at], 'x');
410
+ if (!shape) return fail(`${fn.name} takes a parameter of type ${params[at].slice(0, 40)}; only String, FilePath, Text, ByteString and lists or Maybe of them can be filled with a payload`);
411
+ if (call.first.kind === 'list' && !/^\[/.test(shape.expr)) return fail(`argument-injection: ${call.first.name} is passed as the whole argument vector but has type ${params[at].slice(0, 40)}, not a list of strings`);
412
+ if (call.first.kind === 'element' && /^\[/.test(shape.expr)) return fail(`argument-injection: ${call.first.name} is a list but stands as one element of the argument vector`);
413
+ payloadFor = (i) => (i === at ? spec.payload : 'benign');
414
+ Object.assign(extras, standIn(call.exe));
415
+ assumptions.push(...standInAssumptions(call.exe), `the payload is placed in the parameter \`${call.first.name}\` only; the other parameters get the value "benign"`);
416
+ } else assumptions.push('the payload is passed in every parameter (a String has no request-like stand-in), which is an approximation of how the function is really called');
417
+
418
+ const filled = [];
419
+ let overloaded = false;
420
+ for (let i = 0; i < params.length; i++) {
421
+ const f = resolver.fill(params[i], literal(payloadFor(i)));
422
+ if (!f) return fail(`${fn.name} takes a parameter of type ${params[i].slice(0, 40)}; only String, FilePath, Text, ByteString and lists or Maybe of them can be filled with a payload`);
423
+ filled.push(f.expr); overloaded ||= f.overloaded;
424
+ }
425
+ if (overloaded) assumptions.push('Text and ByteString arguments are written as string literals under OverloadedStrings, set for the call only, not for the program');
426
+ const tail = kind === 'path-traversal-read' && resultInner !== '()' ? '>>= print' : '>>= \\_ -> return ()';
427
+ const call = `${fn.name} ${filled.join(' ')} ${tail}`;
428
+ const reproducer = { ...base, sources, ...(Object.keys(extras.files).length ? { files: extras.files } : {}), ...(extras.build ? { build: extras.build } : {}), ...(extras.env ? { env: extras.env } : {}), run: ghcRun(call, overloaded) };
203
429
  const errs = validateReproducer(reproducer);
204
- return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'function', class: cls.name, entry: rel, line: finding.line, function: `${fn.name}(${params.length} String)`, assumptions } };
430
+ return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'function', class: spec.name, entry: rel, line: finding.line, function: `${fn.name}(${params.length} ${params.every((p) => STRING_TYPES.test(p.trim())) ? 'String' : params.length === 1 ? 'parameter' : 'parameters'})`, assumptions } };
431
+ }
432
+
433
+ function describeFirst(first) {
434
+ return { literal: 'a fixed string', empty: 'absent (an empty list)', complex: 'an expression the proof cannot read as a plain parameter', element: 'a parameter', list: 'a list' }[first.kind] ?? 'unreadable';
435
+ }
436
+
437
+ /** Find the process call at the flagged line and what stands first in its argument vector. */
438
+ function argumentCall(lines, finding) {
439
+ const window = lines.slice(Math.max(0, finding.line - 1), finding.line + 2).join(' ').replace(/\s+/g, ' ');
440
+ const m = PROCESS_CALL.exec(window);
441
+ if (!m) return fail('argument-injection: the flagged line is not a call to callProcess, readProcess, readProcessWithExitCode, spawnProcess, rawSystem or proc with the program named by a string literal, so the proof cannot tell which program receives the arguments');
442
+ const exe = m[2];
443
+ if (NEVER_STAND_IN.has(exe) || !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$/.test(exe)) return fail(`argument-injection: the program is \`${exe}\`, which the proof does not stand in for (a shell, an interpreter or a toolchain program; an argument to a shell is command injection, CWE-78)`);
444
+ const rest = window.slice(m.index + m[0].length).trimStart();
445
+ const first = firstArgument(rest);
446
+ if (first.kind === 'literal' || first.kind === 'empty') return fail(`argument-injection: the first argument of the call to ${exe} is ${describeFirst(first)}, and the proof covers only attacker data in the FIRST position, where every conventional option parser reads a leading dash as an option; later positions depend on the program's own grammar (an option such as -f takes the next word as its value)`);
447
+ return { ok: true, exe, first };
448
+ }
449
+
450
+ /** The stand-in program, its permission and the PATH that finds it. */
451
+ function standIn(exe) {
452
+ return { files: { [`bin/${exe}`]: STAND_IN }, build: ['/bin/chmod', '755', `bin/${exe}`], env: { PATH: STAND_IN_PATH } };
205
453
  }
454
+ const standInAssumptions = (exe) => [`the program \`${exe}\` is replaced by a harness stand-in that acts on one planted option when it is the FIRST argument; a confirmation shows attacker data reaches the first argument unseparated and unvalidated, not that the real ${exe} has a dangerous option`];
@@ -17,6 +17,8 @@
17
17
  // loads it and calls it, filling the tainted parameter with the payload. A parameter that is a request-like object
18
18
  // (`req`) gets a stand-in whose `body`/`query`/`params` answer every property with the payload.
19
19
  //
20
+ // Haskell additionally proves path-traversal READS (a planted secret appears in the output) and argument injection into the first argument
21
+ // of a process call (a stand-in for the called program); see `generate-haskell.js` for what each shows and does not show.
20
22
  // Weakness classes with a payload and an oracle that can SEE the effect: CWE-78 (a canary file created by the injected command),
21
23
  // CWE-22 (a write that lands outside the working directory, only for write sinks), CWE-918 (a request reaching the runner's loopback
22
24
  // listener). Others (injection into a database, deserialization, memory safety) need an environment or a native harness this does not
@@ -27,13 +29,17 @@ import { builtinModules } from 'node:module';
27
29
  import { parse } from '@babel/parser';
28
30
  import { validateReproducer } from './reproducer.js';
29
31
  import { generatePythonReproducer } from './generate-python.js';
30
- import { generateHaskellReproducer } from './generate-haskell.js';
32
+ import { generateHaskellReproducer, haskellRefusal } from './generate-haskell.js';
31
33
 
32
34
  const CLASSES = {
33
35
  'CWE-78': { name: 'command-injection', payload: 'x; touch {{CANARY:command}}', oracle: { type: 'command_execution', params: { canary: 'command' } } },
34
36
  'CWE-22': { needsPy: /\bopen\s*\([^)]*['"][wax]b?\+?['"]|\.write_(?:text|bytes)\s*\(|\bshutil\.(?:copy|move)/, name: 'path-traversal', payload: '../escaped.txt', oracle: { type: 'filesystem_mutation', params: { canary: 'fs' } }, watch: { fs: 'escaped.txt' }, needsHs: /\b(?:writeFile|appendFile|copyFile|copyFileWithMetadata|renameFile|openBinaryFile)\b|\b(?:openFile|withFile|withBinaryFile)\b[^\n]*\b(?:WriteMode|AppendMode|ReadWriteMode)\b/, needs: /\b(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream)\b/ },
35
37
  'CWE-918': { name: 'ssrf', payload: '{{CALLBACK_URL}}', oracle: { type: 'outbound_callback' }, network: 'callback' },
36
38
  };
39
+ // classes only the Haskell generator builds (the other languages refuse them): argument injection needs a stand-in for the called program
40
+ const HASKELL_ONLY = {
41
+ 'CWE-88': { name: 'argument-injection', payload: '--agentic-proof-write={{CANARY:arg}}', oracle: { type: 'argument_injection', params: { canary: 'arg' } } },
42
+ };
37
43
  const MAX_SOURCE = 256 * 1024;
38
44
  const MAX_FILES = 8;
39
45
  const EXTS = ['', '.js', '.mjs', '.cjs', '/index.js'];
@@ -135,11 +141,12 @@ clearTimeout(timer);
135
141
 
136
142
  export function generateReproducer({ finding, root }) {
137
143
  const cwe = normCwe(finding?.cwe);
138
- const cls = CLASSES[cwe];
139
- if (!cls) return fail(`no payload and oracle exist for ${cwe ?? 'this finding'}; supported: ${Object.keys(CLASSES).join(', ')} (others need a database, a deserializer or a native harness)`);
140
144
  const rel = String(finding?.file ?? '');
141
145
  const isPy = /\.py$/i.test(rel);
142
146
  const isHs = /\.hs$/i.test(rel);
147
+ const cls = CLASSES[cwe] ?? (isHs ? HASKELL_ONLY[cwe] : undefined);
148
+ if (isHs && !cls && Number.isInteger(finding?.line)) return haskellRefusal({ finding, root, cwe });
149
+ if (!cls) return fail(`no payload and oracle exist for ${cwe ?? 'this finding'}; supported: ${Object.keys(CLASSES).join(', ')} (others need a database, a deserializer or a native harness)`);
143
150
  if (!isPy && !isHs && !/\.(?:js|mjs|cjs)$/i.test(rel)) return fail(`no harness builder for ${path.extname(rel) || 'this file type'}; JavaScript, Python and Haskell only`);
144
151
  if (!Number.isInteger(finding?.line)) return fail('the finding has no line number');
145
152
  if (isPy) return generatePythonReproducer({ finding, root, cls });
@@ -13,6 +13,7 @@
13
13
  export const ORACLE_TYPES = Object.freeze([
14
14
  'crash_sanitizer', 'command_execution', 'unauthorized_http_access', 'unauthorized_db_read', 'database_mutation',
15
15
  'filesystem_mutation', 'outbound_callback', 'authentication_bypass', 'arbitrary_assertion',
16
+ 'file_disclosure', 'argument_injection',
16
17
  ]);
17
18
 
18
19
  const SANITIZER_RE = /(AddressSanitizer|UndefinedBehaviorSanitizer|stack-buffer-overflow|heap-buffer-overflow|SEGV on unknown address|stack smashing detected|buffer overflow detected)/i;
@@ -68,6 +69,26 @@ const IMPL = {
68
69
  if (obs.timedOut) return inconclusive('run timed out');
69
70
  return out(false, 'protected path unchanged');
70
71
  },
72
+ // A file the runner planted OUTSIDE the directory the program is meant to read from holds a per-run secret. Seeing that secret in the
73
+ // program's output means attacker data steered a read onto it. The target cannot know the secret in advance, so it can print it
74
+ // only by reading the file. A read that leaves nothing in stdout/stderr is invisible to this oracle (it stays unproven, not refuted).
75
+ file_disclosure(obs, p) {
76
+ const secret = obs.secrets?.[p.secret || 'file'];
77
+ if (!secret) return inconclusive('disclosure canary was not armed by the runner');
78
+ if (has(obs, secret)) return out(true, 'content of a planted file outside the permitted directory appeared in the output');
79
+ if (obs.timedOut) return inconclusive('run timed out');
80
+ return out(false, 'planted file content not observed');
81
+ },
82
+ // A stand-in for the called program (planted by the runner, never the target) acts on exactly one option, given as its FIRST argument,
83
+ // by creating a nonce-named file. The file exists only if attacker data reached the first argument of the call unseparated and
84
+ // unvalidated. It shows the option position is attacker-controlled; it does not show the real program has a dangerous option.
85
+ argument_injection(obs, p) {
86
+ const c = obs.canaries?.[p.canary || 'arg'];
87
+ if (!c) return inconclusive('argument canary was not armed by the runner');
88
+ if (c.exists && !c.existedBefore) return out(true, 'a harness-planted option supplied as attacker data reached the first argument of the called program');
89
+ if (obs.timedOut) return inconclusive('run timed out before the canary could be judged');
90
+ return out(false, 'canary file not created');
91
+ },
71
92
  outbound_callback(obs) {
72
93
  if (obs.callbackUnavailable) return { fired: false, unsupported: true, reason: 'loopback callback listener is not available on this backend' };
73
94
  const hit = (obs.callbacks || []).some((c) => c && c.nonce && c.nonce === obs.nonce);
@@ -36,7 +36,8 @@ function basenameLike(file) {
36
36
  function pathShape(f) {
37
37
  if (!f) return '';
38
38
  const segments = [];
39
- if (f.source) segments.push(`${f.source.type || ''}@${f.source.label || ''}`);
39
+ // a parameter source (`kind: 'parameter'`, named by the Haskell evidence chain) is not part of the identity: a finding keeps the id it had before its source was named
40
+ if (f.source && f.source.kind !== 'parameter') segments.push(`${f.source.type || ''}@${f.source.label || ''}`);
40
41
  if (Array.isArray(f.pathSteps)) {
41
42
  for (const step of f.pathSteps) {
42
43
  segments.push(`${step.type || ''}@${step.label || ''}`);
@@ -583,6 +583,9 @@ export function normalizeFindings(scan){
583
583
  blastRadius: sc.blastRadius || null,
584
584
  cvssVector: sc.cvssVector || null,
585
585
  functionReachable: sc.functionReachable || null,
586
+ // Hackage: the reason behind an `unknown` (no module mapping for the package is NOT "unreachable"), the modules that
587
+ // matched, and the tier. Dropping them left a bare `unknown` that read like a verdict.
588
+ ...(sc.reachability && typeof sc.reachability === 'object' ? { reachability: sc.reachability, reachabilityTier: sc.reachabilityTier || null } : {}),
586
589
  // 0.10.0: CISA KEV — actively abused in the wild
587
590
  kev: sc.kev === true,
588
591
  kevDateAdded: sc.kevDateAdded || null,
@@ -363,7 +363,9 @@ export function scanCloudIam(fp, raw) {
363
363
  if (!raw || raw.length > 500_000) return [];
364
364
  const out = [];
365
365
  const seen = new Set();
366
- const isJson = /\.json$/i.test(fp) || raw.trimStart().startsWith('{');
366
+ // A file is JSON by its extension or because it OPENS like a JSON object: a brace, then a quoted key or a closing brace. A bare leading `{` is not
367
+ // enough: a Haskell module that starts with a `{-# LANGUAGE ... #-}` pragma (or a `{- ... -}` comment) begins with one too, and its prose was read as policy.
368
+ const isJson = /\.json$/i.test(fp) || /^\s*\{\s*(?:"|\})/.test(raw.slice(0, 4096));
367
369
  const isYaml = /\.(?:yaml|yml)$/i.test(fp);
368
370
  const isBicep = /\.bicep$/i.test(fp);
369
371
  const isTf = /\.tf$/i.test(fp);