@clear-capabilities/agentic-security-scanner 0.189.0 → 0.190.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 +9 -0
- package/bin/agentic-security.js +18 -1
- package/dist/{1602.index.js → 2066.index.js} +7 -463
- package/dist/{051c3705cb9cb458cd2f.json → 2d84fed7f6258f8552e1.json} +2 -2
- package/dist/8798.index.js +100 -0
- package/dist/9560.index.js +2 -2
- package/dist/9926.index.js +477 -0
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +2 -2
- package/src/language/CLAUDE.md +7 -2
- package/src/language/context.js +32 -5
- package/src/language/haskell-fix.js +495 -28
- package/src/language/witness.js +96 -0
- package/src/mcp/tools.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
> make the history less accurate, not more.
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
## 0.190.0 - Haskell remediation: four more verified fixers, and refusals that name the decision
|
|
13
|
+
|
|
14
|
+
- **Four new fixers (ten in total).** TLS validation (`connection` `TLSSettingsSimple` literal `True` to `False`), cookie attributes (a `Set-Cookie` header literal, a `Web.Cookie` record update, servant-auth `NotSecure`, scotty simple cookies), predictable randomness (`randomIO` replaced by OS entropy, only for a fixed-width integer in a file that already imports `System.Entropy`) and an unbounded request body (a mitigation: a bounded reader with a 1 MiB limit that is a declared default, not a measured need). Each has a paired refusal case and is checked by a rescan, an independent effective-change check and a compile against declared stubs of the package API. The randomness, cookie-header and request-body fixes are also run against benign and hostile inputs under GHC.
|
|
15
|
+
- **Refusals name the decision.** Password hashing (the missing KDF dependency and the migration of stored hashes), SSRF, argument injection, missing authentication and hard-coded secrets still refuse, and each reason now ends with the decision the operator has to make.
|
|
16
|
+
- **Compile in the preview.** `fix` previews type-check the patched file by default and report `not-run` when GHC is missing or the original does not type-check on the host; that is never shown as a pass. A fix witness (ghc -e, original versus patched) exists for command-injection fixes in files that import only boot packages.
|
|
17
|
+
- **Verifying someone else's patch.** `fix --finding <id> --proposal <file>` runs a supplied patch through the same gates and never writes.
|
|
18
|
+
- **Real vulnerabilities, per finding.** Patched: one cookie finding in scotty's session example and two request-body reads in IHP's middleware. Refused with a named decision: gitit and hackage-server password hashing and tokens, scotty's session id, IHP's disabled certificate verification, dhall's path handling, and the example XSS, redirect and body-read findings. No patch to a real project was compile-checked, because the originals do not type-check on the machine used; the compile evidence is against stubs, which shows coherence with the documented API, not a build against the real package.
|
|
19
|
+
- **Limits.** The cookie-header fixer handles single-line tuples only and never touches SameSite. The new `entropy` and `bytestring` requirements and the request-body limit are stated in each plan's consequences but not checked against a cabal file.
|
|
20
|
+
|
|
12
21
|
## 0.189.0 - Caller-aware reachability, and a precision measurement that can catch a regression
|
|
13
22
|
|
|
14
23
|
- **Project reachability for Haskell.** Each function now gets one of four verdicts: `entrypoint-reachable`, `unreachable-in-project`, `external-api` or `unknown`. A negative verdict needs a complete scan, a hole-free call graph, a real entry point, a known exposed-module list and a function that is not an instance method; anything less is `unknown`, never "unreachable". Template Haskell, `foreign export`, the function cap, unresolved names and withheld code are holes. The verdict is a separate record from the trust-boundary verdict.
|
package/bin/agentic-security.js
CHANGED
|
@@ -3662,6 +3662,18 @@ async function cmdFix(args) {
|
|
|
3662
3662
|
return 4;
|
|
3663
3663
|
}
|
|
3664
3664
|
const proj = loadLanguageProject(scanRoot);
|
|
3665
|
+
// --proposal <file>: verify a patch someone else wrote (a person, a model) for this finding's file through the same gates as a
|
|
3666
|
+
// deterministic fix (syntax, rescan, compile). It never writes.
|
|
3667
|
+
if (args.flags.proposal) {
|
|
3668
|
+
if (isApply) { console.error('--proposal only verifies a patch; it never writes. Apply the patch yourself once it verifies.'); return 4; }
|
|
3669
|
+
let proposalText;
|
|
3670
|
+
try { proposalText = fs.readFileSync(path.resolve(String(args.flags.proposal)), 'utf8'); } catch (e) { console.error(`Cannot read --proposal file: ${e.message}`); return 4; }
|
|
3671
|
+
const { languageProposalPreview } = await import('../src/language/context.js');
|
|
3672
|
+
const pv = await languageProposalPreview(f, proj.files, { file: f.file, proposal: proposalText });
|
|
3673
|
+
console.log(pv.diff);
|
|
3674
|
+
console.log(`\nProposal ${pv.ok ? 'verified' : 'NOT verified'} (${pv.status}): ${Object.entries(pv.gates || {}).map(([k, v]) => `${k}=${v && v.ok === false ? 'FAILED' : (v && v.ran === false ? 'not-run' : 'ok')}`).join(' ')}${pv.reason ? `\n ${pv.reason}` : ''}`);
|
|
3675
|
+
return pv.ok ? 0 : 4;
|
|
3676
|
+
}
|
|
3665
3677
|
// --require-proof applies to a Haskell fix exactly as to any other: the verified patch is previewed first, the exploit is
|
|
3666
3678
|
// confirmed against the original and blocked against the patch, and only then is the same patch written.
|
|
3667
3679
|
let provenFiles = null;
|
|
@@ -3682,7 +3694,12 @@ async function cmdFix(args) {
|
|
|
3682
3694
|
console.log(res.diff);
|
|
3683
3695
|
for (const c of res.consequences || []) console.log(` note: ${c}`);
|
|
3684
3696
|
if (isApply) { console.log(`\nApplied. Verified: ${Object.entries(res.gates || {}).map(([k, v]) => `${k}=${v && v.ok === false ? 'FAILED' : (v && v.ran === false ? 'not-run' : 'ok')}`).join(' ')}. Revert with \`agentic-security undo\`.`); }
|
|
3685
|
-
else
|
|
3697
|
+
else {
|
|
3698
|
+
const g = res.gates || {};
|
|
3699
|
+
const compile = g.compile && g.compile.ran ? (g.compile.ok ? 'passed' : 'FAILED') : 'not-run';
|
|
3700
|
+
console.log(`\nVerified (syntax, rescan: the finding is gone and nothing new appears${g.effective && g.effective.ran ? '; effective-change: the intended attribute reads back changed' : ''}; compile: ${compile}). Run with --apply to write this change. Use \`agentic-security undo\` to revert.`);
|
|
3701
|
+
if (compile === 'not-run') console.log(` compile was not run: ${(g.compile && g.compile.detail) || 'not requested'}`);
|
|
3702
|
+
}
|
|
3686
3703
|
return 0;
|
|
3687
3704
|
}
|
|
3688
3705
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export const id =
|
|
2
|
-
export const ids = [
|
|
1
|
+
export const id = 2066;
|
|
2
|
+
export const ids = [2066,2199];
|
|
3
3
|
export const modules = {
|
|
4
4
|
|
|
5
|
-
/***/
|
|
5
|
+
/***/ 92066:
|
|
6
6
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
7
|
|
|
8
8
|
// ESM COMPAT FLAG
|
|
@@ -168,464 +168,8 @@ function generatePythonReproducer({ finding, root, cls }) {
|
|
|
168
168
|
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}(${fn.args.join(', ')})`, assumptions } };
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
// EXTERNAL MODULE: ./src/
|
|
172
|
-
var
|
|
173
|
-
;// CONCATENATED MODULE: ./src/posture/proof-fabric/generate-haskell.js
|
|
174
|
-
// Reproducer generation for Haskell (PRD 1), the third language of `generate.js`. Same contract as JavaScript and Python: a
|
|
175
|
-
// DETERMINISTIC proposal built from the flagged file alone, then run through the ordinary runner, oracle, 3-of-3 repetition and
|
|
176
|
-
// independent replay. A generated reproducer is a proposal that execution either confirms or does not.
|
|
177
|
-
//
|
|
178
|
-
// The program is run by GHC's interpreter (`ghc -e`), not compiled. Why: it needs no C compiler and no linker (so no dependence on
|
|
179
|
-
// the platform's developer tools), it leaves no object files or binaries to account for, it starts in about half a second where a
|
|
180
|
-
// compile-then-run takes a second or more, and there is one process tree to confine instead of a build stage followed by a run
|
|
181
|
-
// stage. Interpreted bytecode calls the same installed libraries a compiled program would, so a shell string reaches the same
|
|
182
|
-
// `System.Process` either way. The tradeoff is stated, not hidden: a program whose behaviour depends on compiled code (FFI, an
|
|
183
|
-
// optimisation-sensitive race) is refused, and `-O` is never in play. A hand-written reproducer may still use `build`/`run` with
|
|
184
|
-
// `{{GHC}}` to compile; both paths run under the same sandbox, timeout and output cap.
|
|
185
|
-
//
|
|
186
|
-
// Two shapes, as in the other languages:
|
|
187
|
-
// script the file reads `getArgs`: it is run as `:main "<payload>"`, so the payload arrives as the first argument;
|
|
188
|
-
// function a top-level function around the flagged line that has a type signature of `String`/`FilePath` parameters and an `IO`
|
|
189
|
-
// result: `ghc -e 'fn "<payload>" ...'` loads the module with every top-level name in scope (the module need not export
|
|
190
|
-
// it) and calls it with the payload in every parameter.
|
|
191
|
-
//
|
|
192
|
-
// Only what the proof environment can load is accepted: the file's imports must be modules of the packages that ship with GHC (the
|
|
193
|
-
// global package database, read from the installed GHC) or other `.hs` files in the repository. A web framework, a database binding
|
|
194
|
-
// or any Hackage package is refused with the package named where it is known. Template Haskell, quasi-quotes, CPP, the foreign
|
|
195
|
-
// function interface and compiler-plugin options are refused too: they run code at load time, before the payload is involved.
|
|
196
|
-
//
|
|
197
|
-
// Classes, each with an oracle that sees its effect and nothing else:
|
|
198
|
-
// command injection (CWE-78) a nonce-named canary file the injected command alone could create;
|
|
199
|
-
// path traversal by a WRITE a file created or changed outside the directory the program writes to (CWE-22);
|
|
200
|
-
// path traversal by a READ a per-run secret, planted in a file one directory above the program's base directory, appears in the
|
|
201
|
-
// program's output (stdout, stderr or, for a function, its printed result). A read whose content never
|
|
202
|
-
// reaches the output leaves nothing to see and stays unproven;
|
|
203
|
-
// argument injection (CWE-88) the called program is replaced by a harness stand-in that acts on one planted option, but only when that
|
|
204
|
-
// option is its FIRST argument; the canary exists only if attacker data reached the first position of the
|
|
205
|
-
// argument vector. It shows the option position is attacker-controlled, not that the real program has a
|
|
206
|
-
// dangerous option, and the generated reproducer says so in its assumptions.
|
|
207
|
-
// Refused on purpose, with the construct named and the way to supply a hand-written reproducer: SSRF (no HTTP client ships with GHC), SQL
|
|
208
|
-
// (no database binding ships with GHC), deserialization, XSS, code injection, argument injection past the first argument, reads and writes
|
|
209
|
-
// whose base directory the file does not state, and any function the proof cannot fill or call. Nothing is executed on a refusal.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const generate_haskell_MAX_SOURCE = 256 * 1024;
|
|
218
|
-
const generate_haskell_MAX_FILES = 8;
|
|
219
|
-
const generate_haskell_fail = (reason, extra = {}) => ({ ok: false, reason, ...extra });
|
|
220
|
-
|
|
221
|
-
// Well-known packages that are NOT part of a GHC install, by module prefix, so a refusal can name the package to install.
|
|
222
|
-
const THIRD_PARTY = [
|
|
223
|
-
['Web.Scotty', 'scotty'], ['Network.Wai', 'wai'], ['Network.HTTP.Types', 'http-types'], ['Network.HTTP.Client', 'http-client'],
|
|
224
|
-
['Network.HTTP.Simple', 'http-conduit'], ['Network.HTTP.Conduit', 'http-conduit'], ['Network.Wreq', 'wreq'], ['Network.Socket', 'network'],
|
|
225
|
-
['Servant', 'servant'], ['Yesod', 'yesod'], ['Snap', 'snap'], ['Happstack', 'happstack-server'], ['Data.Aeson', 'aeson'],
|
|
226
|
-
['Database.SQLite.Simple', 'sqlite-simple'], ['Database.PostgreSQL.Simple', 'postgresql-simple'], ['Database.Persist', 'persistent'],
|
|
227
|
-
['Database.MySQL', 'mysql-simple'], ['System.Random', 'random'], ['Data.Vector', 'vector'], ['Data.Hashable', 'hashable'],
|
|
228
|
-
['Data.HashMap', 'unordered-containers'], ['Control.Lens', 'lens'], ['Test.', 'a test framework'], ['Turtle', 'turtle'],
|
|
229
|
-
['Shelly', 'shelly'], ['System.Process.Typed', 'typed-process'], ['Conduit', 'conduit'], ['Control.Monad.Logger', 'monad-logger'],
|
|
230
|
-
['Options.Applicative', 'optparse-applicative'], ['Data.Text.Lazy.Builder', null],
|
|
231
|
-
];
|
|
232
|
-
|
|
233
|
-
const libCache = new Map();
|
|
234
|
-
/** The modules the installed GHC exposes from its global package database (what a program can import with no extra packages). */
|
|
235
|
-
function bootModuleSet(ghc) {
|
|
236
|
-
if (libCache.has(ghc)) return libCache.get(ghc);
|
|
237
|
-
let set = null;
|
|
238
|
-
try {
|
|
239
|
-
const r = (0,external_node_child_process_.spawnSync)(ghc, ['--print-libdir'], { encoding: 'utf8', timeout: 10_000 });
|
|
240
|
-
const dir = r.status === 0 ? external_node_path_.join(r.stdout.trim(), 'package.conf.d') : null;
|
|
241
|
-
if (dir && external_node_fs_.existsSync(dir)) {
|
|
242
|
-
set = new Set();
|
|
243
|
-
for (const f of external_node_fs_.readdirSync(dir)) {
|
|
244
|
-
if (!f.endsWith('.conf')) continue;
|
|
245
|
-
const text = external_node_fs_.readFileSync(external_node_path_.join(dir, f), 'utf8');
|
|
246
|
-
if (/^exposed:\s*False/m.test(text)) continue;
|
|
247
|
-
const m = /^exposed-modules:\s*\n?([\s\S]*?)(?=^\S)/m.exec(text);
|
|
248
|
-
if (!m) continue;
|
|
249
|
-
for (const tok of m[1].split(/[\s,]+/)) if (/^[A-Z][\w']*(?:\.[A-Z][\w']*)*$/.test(tok)) set.add(tok);
|
|
250
|
-
}
|
|
251
|
-
if (!set.has('Prelude')) set = null; // a database that does not even list Prelude was not read correctly
|
|
252
|
-
}
|
|
253
|
-
} catch { set = null; }
|
|
254
|
-
libCache.set(ghc, set);
|
|
255
|
-
return set;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function generate_haskell_readInside(root, rel) {
|
|
259
|
-
let real; try { real = external_node_fs_.realpathSync(external_node_path_.resolve(root, rel)); } catch { return null; }
|
|
260
|
-
const base = external_node_fs_.realpathSync(root);
|
|
261
|
-
if (!(real === base || real.startsWith(base + external_node_path_.sep))) return null;
|
|
262
|
-
const st = external_node_fs_.statSync(real);
|
|
263
|
-
return st.isFile() && st.size <= generate_haskell_MAX_SOURCE ? { real, text: external_node_fs_.readFileSync(real, 'utf8') } : null;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Split a type on its top-level arrows (arrows inside parentheses or brackets belong to a function-typed argument). */
|
|
267
|
-
function splitArrows(type) {
|
|
268
|
-
const parts = []; let depth = 0; let cur = '';
|
|
269
|
-
for (let i = 0; i < type.length; i++) {
|
|
270
|
-
const c = type[i];
|
|
271
|
-
if ('([{'.includes(c)) depth++; else if (')]}'.includes(c)) depth--;
|
|
272
|
-
if (depth === 0 && c === '-' && type[i + 1] === '>') { parts.push(cur.trim()); cur = ''; i++; continue; }
|
|
273
|
-
cur += c;
|
|
274
|
-
}
|
|
275
|
-
parts.push(cur.trim());
|
|
276
|
-
return parts;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Compile-time code execution and options that change what runs before the payload is involved.
|
|
280
|
-
function loadTimeHazard(text) {
|
|
281
|
-
if (/\{-#\s*LANGUAGE[^#]*\b(?:TemplateHaskell|TemplateHaskellQuotes|QuasiQuotes|CPP|ForeignFunctionInterface|CApiFFI)\b/i.test(text)) return 'it enables Template Haskell, quasi-quotes, CPP or the foreign function interface, which run or require code at load time';
|
|
282
|
-
if (/\{-#\s*OPTIONS(?:_GHC)?\b[^#]*(?:-fplugin|-pgm|-opt[a-zA-Z]|-F\b|-cpp|-fobject-code)/i.test(text)) return 'its OPTIONS_GHC pragma names a plugin, a preprocessor or an external program';
|
|
283
|
-
if (/^\s*foreign\s+(?:import|export)\b/m.test(text)) return 'it uses the foreign function interface';
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function classify(mod, pkg, boot) {
|
|
288
|
-
if (pkg) return { ok: false, name: String(pkg).replace(/^"|"$/g, '') };
|
|
289
|
-
if (boot.has(mod)) return { ok: true };
|
|
290
|
-
for (const [prefix, name] of THIRD_PARTY) if (mod === prefix || mod.startsWith(prefix.endsWith('.') ? prefix : `${prefix}.`)) return { ok: false, name: name ?? mod };
|
|
291
|
-
return { ok: false, name: mod };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ---------------------------------------------------------------------------------------------------------------- parameter types
|
|
295
|
-
|
|
296
|
-
const STRING_TYPES = /^(?:String|Prelude\.String|FilePath|System\.IO\.FilePath|\[Char\])$/;
|
|
297
|
-
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; };
|
|
298
|
-
|
|
299
|
-
/** 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. */
|
|
300
|
-
function typeResolver(info, text) {
|
|
301
|
-
const textQ = new Set(); const bytesQ = new Set(); let textBare = false; let bytesBare = false;
|
|
302
|
-
for (const imp of info.imports ?? []) {
|
|
303
|
-
const isText = /^Data\.Text(?:\.Lazy)?$/.test(imp.module);
|
|
304
|
-
const isBytes = /^Data\.ByteString(?:\.(?:Char8|Lazy|Lazy\.Char8))?$/.test(imp.module);
|
|
305
|
-
if (!isText && !isBytes) continue;
|
|
306
|
-
const bare = !imp.qualified && !imp.hiding && (imp.items == null || imp.items.some((i) => /^(?:Text|ByteString)\b/.test(i)));
|
|
307
|
-
if (isText) { textQ.add(imp.as ?? imp.module); textBare ||= bare; } else { bytesQ.add(imp.as ?? imp.module); bytesBare ||= bare; }
|
|
308
|
-
}
|
|
309
|
-
const localDef = (n) => new RegExp(`^(?:data|newtype)\\s+${n}\\b`, 'm').test(text);
|
|
310
|
-
if (localDef('Text')) textBare = false;
|
|
311
|
-
if (localDef('ByteString')) bytesBare = false;
|
|
312
|
-
const aliases = new Map();
|
|
313
|
-
for (const m of text.matchAll(/^type\s+([A-Z][\w']*)\s*=\s*([^\n=]+?)\s*(?:--.*)?$/gm)) aliases.set(m[1], m[2]);
|
|
314
|
-
const resolve = (t, depth = 0) => {
|
|
315
|
-
const x = unparen(t);
|
|
316
|
-
if (depth < 4 && aliases.has(x)) return resolve(aliases.get(x), depth + 1);
|
|
317
|
-
return x;
|
|
318
|
-
};
|
|
319
|
-
const stringish = (t) => {
|
|
320
|
-
const x = resolve(t);
|
|
321
|
-
if (STRING_TYPES.test(x)) return 'string';
|
|
322
|
-
const q = /^(?:([A-Z][\w.]*)\.)?(Text|ByteString)$/.exec(x);
|
|
323
|
-
if (!q) return null;
|
|
324
|
-
if (q[2] === 'Text') return q[1] ? (textQ.has(q[1]) ? 'overloaded' : null) : (textBare ? 'overloaded' : null);
|
|
325
|
-
return q[1] ? (bytesQ.has(q[1]) ? 'overloaded' : null) : (bytesBare ? 'overloaded' : null);
|
|
326
|
-
};
|
|
327
|
-
/** `{lit}` is the Haskell expression for a value of this type holding `value`, `{needsOverloaded}` whether it relies on OverloadedStrings. */
|
|
328
|
-
const fill = (t, value) => {
|
|
329
|
-
const x = resolve(t);
|
|
330
|
-
const one = stringish(x);
|
|
331
|
-
if (one) return { expr: value, overloaded: one === 'overloaded' };
|
|
332
|
-
const list = /^\[\s*(.+?)\s*\]$/.exec(x);
|
|
333
|
-
if (list) { const e = stringish(list[1]); if (e) return { expr: `[${value}]`, overloaded: e === 'overloaded' }; }
|
|
334
|
-
const maybe = /^Maybe\s+(.+)$/.exec(x);
|
|
335
|
-
if (maybe) { const e = stringish(maybe[1]); if (e) return { expr: `(Just ${value})`, overloaded: e === 'overloaded' }; }
|
|
336
|
-
return null;
|
|
337
|
-
};
|
|
338
|
-
/** The result can be shown by `print` and carries the content a read returned (String, Text, ByteString, lists and Maybe of them). */
|
|
339
|
-
const printable = (t) => {
|
|
340
|
-
const x = resolve(t);
|
|
341
|
-
return !!(stringish(x) || (/^\[\s*(.+?)\s*\]$/.exec(x) && stringish(/^\[\s*(.+?)\s*\]$/.exec(x)[1])) || (/^Maybe\s+(.+)$/.exec(x) && stringish(/^Maybe\s+(.+)$/.exec(x)[1])));
|
|
342
|
-
};
|
|
343
|
-
return { fill, printable, resolve };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// ---------------------------------------------------------------------------------------------------------------- path traversal: write or read
|
|
347
|
-
|
|
348
|
-
const READ_SINK = /\b(?:readFile'?|readFileBinary)\b|\b(?:openFile|withFile|openBinaryFile|withBinaryFile)\b[^\n]*\bReadMode\b/;
|
|
349
|
-
const READ_PAYLOAD = '../agentic-proof-secret.txt';
|
|
350
|
-
const READ_SECRET_FILE = 'agentic-proof-secret.txt';
|
|
351
|
-
const READ_ORACLE = { type: 'file_disclosure', params: { secret: 'file' } };
|
|
352
|
-
const WRITE_CONSTRUCTS = 'writeFile, appendFile, copyFile, renameFile, or openFile/withFile in WriteMode/AppendMode';
|
|
353
|
-
const READ_CONSTRUCTS = 'readFile, or openFile/withFile in ReadMode';
|
|
354
|
-
|
|
355
|
-
/** Which path operation the flagged line is: its own line first, then the lines around it. */
|
|
356
|
-
function pathKind(sinkLine, around, writeRe) {
|
|
357
|
-
if (writeRe.test(sinkLine)) return 'write';
|
|
358
|
-
if (READ_SINK.test(sinkLine)) return 'read';
|
|
359
|
-
if (writeRe.test(around)) return 'write';
|
|
360
|
-
if (READ_SINK.test(around)) return 'read';
|
|
361
|
-
return null;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* 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
|
|
366
|
-
* bound to a name that says directory. The proof creates them (empty) so that `dir/../secret` resolves; a program whose base is not a
|
|
367
|
-
* relative literal cannot be modelled and is refused rather than guessed.
|
|
368
|
-
*/
|
|
369
|
-
function readBaseDirs(text) {
|
|
370
|
-
const found = [];
|
|
371
|
-
const add = (lit) => {
|
|
372
|
-
if (!lit || lit.startsWith('/') || lit.includes('..') || lit.includes('\\') || /[{}\s:*?"<>|]/.test(lit)) return;
|
|
373
|
-
const seg = lit.split('/').filter(Boolean)[0];
|
|
374
|
-
if (!seg || seg === '.' || ['canary', 'tmp'].includes(seg) || !/^[A-Za-z0-9._-]{1,32}$/.test(seg) || seg.startsWith('__oracle_')) return;
|
|
375
|
-
if (!found.includes(seg)) found.push(seg);
|
|
376
|
-
};
|
|
377
|
-
for (const m of text.matchAll(/"([^"\\\n]+\/)"/g)) add(m[1]);
|
|
378
|
-
for (const m of text.matchAll(/"([^"\\\n]+)"\s*<\/>/g)) add(m[1]);
|
|
379
|
-
for (const m of text.matchAll(/<\/>\s*"([^"\\\n]+)"/g)) add(m[1]);
|
|
380
|
-
for (const m of text.matchAll(/\b[a-z]\w*(?:Dir|Base|Root|Folder|Path|Directory)\w*'?\s*=\s*"([^"\\\n]+)"/gi)) add(m[1]);
|
|
381
|
-
return found.slice(0, 4);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// ---------------------------------------------------------------------------------------------------------------- argument injection
|
|
385
|
-
|
|
386
|
-
// 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
|
|
387
|
-
// a toolchain program on PATH would break the proof rather than test the finding.
|
|
388
|
-
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']);
|
|
389
|
-
const PROCESS_CALL = /\b(callProcess|readProcess|readProcessWithExitCode|spawnProcess|rawSystem|proc)\s+(?:\$\s*)?"([^"\\\s]+)"\s*/;
|
|
390
|
-
const STAND_IN = `#!/bin/sh
|
|
391
|
-
# Stand-in for the program the code under test calls. It is planted by the proof runner, never by the target.
|
|
392
|
-
# It acts on exactly one option, and only when that option is its FIRST argument, by creating the file the option names.
|
|
393
|
-
case "$1" in
|
|
394
|
-
--agentic-proof-write=*) : > "\${1#--agentic-proof-write=}" ;;
|
|
395
|
-
esac
|
|
396
|
-
exit 0
|
|
397
|
-
`;
|
|
398
|
-
// PATH puts the stand-in directory first and keeps the system and toolchain directories after it
|
|
399
|
-
const STAND_IN_PATH = '{{ROOT}}/bin:/opt/ghc/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/homebrew/bin';
|
|
400
|
-
|
|
401
|
-
/** What stands in first position of the argument vector at the call: a named element, a whole list, a fixed string, or something else. */
|
|
402
|
-
function firstArgument(rest) {
|
|
403
|
-
if (rest.startsWith('[')) {
|
|
404
|
-
const inner = rest.slice(1).trimStart();
|
|
405
|
-
if (inner.startsWith('"')) return { kind: 'literal' };
|
|
406
|
-
if (inner.startsWith(']')) return { kind: 'empty' };
|
|
407
|
-
const m = /^([a-z_][\w']*)\s*(?:,|\])/.exec(inner);
|
|
408
|
-
return m ? { kind: 'element', name: m[1] } : { kind: 'complex' };
|
|
409
|
-
}
|
|
410
|
-
if (rest.startsWith('(')) {
|
|
411
|
-
const m = /^\(\s*([a-z_][\w']*)\s*:/.exec(rest);
|
|
412
|
-
return m ? { kind: 'element', name: m[1] } : { kind: 'complex' };
|
|
413
|
-
}
|
|
414
|
-
const m = /^([a-z_][\w']*)\s*(?:$|\)|"|--)/.exec(rest);
|
|
415
|
-
return m ? { kind: 'list', name: m[1] } : { kind: 'complex' };
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
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.';
|
|
419
|
-
|
|
420
|
-
/** What the flagged line is, in words, for a refusal message. */
|
|
421
|
-
function describeLine(finding, root) {
|
|
422
|
-
const src = (() => { try { return generate_haskell_readInside(root, String(finding.file)); } catch { return null; } })();
|
|
423
|
-
const line = src ? (src.text.split('\n')[finding.line - 1] ?? '').trim().replace(/\s+/g, ' ').slice(0, 90) : '';
|
|
424
|
-
return line ? `the flagged line \`${line}\`` : 'the flagged code';
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const REFUSALS = {
|
|
428
|
-
'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.`,
|
|
429
|
-
'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.`,
|
|
430
|
-
'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.`,
|
|
431
|
-
'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.`,
|
|
432
|
-
'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.`,
|
|
433
|
-
'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.`,
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* The reason a Haskell finding of a class this generator does not cover is refused, naming the construct and the way out.
|
|
438
|
-
* @param {{finding: object, root: string, cwe: string|null}} o
|
|
439
|
-
*/
|
|
440
|
-
function haskellRefusal({ finding, root, cwe }) {
|
|
441
|
-
const what = describeLine(finding, root);
|
|
442
|
-
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).`;
|
|
443
|
-
return generate_haskell_fail(`${body} ${HINT}`);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// ---------------------------------------------------------------------------------------------------------------- generation
|
|
447
|
-
|
|
448
|
-
/** @param {{finding: object, root: string, cls: {name: string, payload: string, oracle: object, network?: string, watch?: object, needsHs?: RegExp}, bootModules?: Set<string>}} o */
|
|
449
|
-
function generateHaskellReproducer(o) {
|
|
450
|
-
const r = generateInner(o);
|
|
451
|
-
if (r.ok || r.reason.includes('Nothing was executed.')) return r;
|
|
452
|
-
return { ...r, reason: `${r.reason.replace(/\.$/, '')}. ${HINT}` };
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function generateInner({ finding, root, cls, bootModules }) {
|
|
456
|
-
const rel = String(finding.file);
|
|
457
|
-
if (cls.name === 'ssrf') return haskellRefusal({ finding, root, cwe: 'CWE-918' });
|
|
458
|
-
const src = generate_haskell_readInside(root, rel);
|
|
459
|
-
if (!src) return generate_haskell_fail('the flagged file is missing, outside the repository, or too large');
|
|
460
|
-
const hazard = loadTimeHazard(src.text);
|
|
461
|
-
if (hazard) return generate_haskell_fail(`the flagged file is refused: ${hazard}`);
|
|
462
|
-
if (/\{\{[A-Z_]+(?::[a-z0-9_-]{1,32})?\}\}/.test(src.text)) return generate_haskell_fail('the flagged file contains text shaped like a proof placeholder ({{NAME}}), which the runner would substitute inside the program');
|
|
463
|
-
const info = (0,haskell_parser/* parseHaskell */.cX)(src.text);
|
|
464
|
-
if (!info || info.status !== 'parsed') return generate_haskell_fail('the flagged file could not be parsed as Haskell');
|
|
465
|
-
const lines = src.text.split('\n');
|
|
466
|
-
const sinkLine = lines[finding.line - 1] ?? '';
|
|
467
|
-
const around = lines.slice(Math.max(0, finding.line - 3), finding.line + 2).join('\n');
|
|
468
|
-
|
|
469
|
-
// the class decides what is planted and what is watched; for a path operation the flagged line decides which of two it is
|
|
470
|
-
let spec = cls;
|
|
471
|
-
let kind = cls.name;
|
|
472
|
-
if (cls.name === 'path-traversal') {
|
|
473
|
-
const k = pathKind(sinkLine, around, cls.needsHs);
|
|
474
|
-
if (!k) return generate_haskell_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`);
|
|
475
|
-
if (k === 'read') { kind = 'path-traversal-read'; spec = { name: 'path-traversal-read', payload: READ_PAYLOAD, oracle: READ_ORACLE }; }
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const tools = (0,runner/* hostTools */.qV)();
|
|
479
|
-
const boot = bootModules ?? (tools.ghc ? bootModuleSet(tools.ghc) : null);
|
|
480
|
-
if (!boot) return generate_haskell_fail('no working GHC was found to list the modules the proof environment can import (install ghc, or run `agentic-security proof doctor`)');
|
|
481
|
-
|
|
482
|
-
// the entry module is placed at its module path so that other modules can import it; Main goes to Main.hs
|
|
483
|
-
const modName = info.module?.name ?? 'Main';
|
|
484
|
-
const entry = `${modName.replaceAll('.', '/')}.hs`;
|
|
485
|
-
const rootReal = external_node_fs_.realpathSync(root);
|
|
486
|
-
const entryDir = external_node_path_.dirname(src.real);
|
|
487
|
-
const modRel = modName.replaceAll('.', external_node_path_.sep);
|
|
488
|
-
const srcRoot = src.real.endsWith(`${modRel}.hs`) ? src.real.slice(0, src.real.length - modRel.length - 3) : `${entryDir}${external_node_path_.sep}`;
|
|
489
|
-
const searchRoots = [...new Set([srcRoot.replace(/[\\/]$/, ''), ...['src', 'app', 'lib', ''].map((d) => external_node_path_.join(rootReal, d))])];
|
|
490
|
-
const sources = {}; const external = []; const seen = new Set([modName]);
|
|
491
|
-
let count = 0;
|
|
492
|
-
const visit = (imports) => {
|
|
493
|
-
for (const imp of imports) {
|
|
494
|
-
const mod = imp.module;
|
|
495
|
-
if (seen.has(mod)) continue;
|
|
496
|
-
seen.add(mod);
|
|
497
|
-
let local = null;
|
|
498
|
-
for (const base of searchRoots) { const cand = external_node_path_.join(base, `${mod.replaceAll('.', '/')}.hs`); const r = generate_haskell_readInside(root, external_node_path_.relative(rootReal, cand)); if (r) { local = r; break; } }
|
|
499
|
-
if (local && !imp.package) {
|
|
500
|
-
if (++count > generate_haskell_MAX_FILES) return generate_haskell_fail(`the file pulls in more than ${generate_haskell_MAX_FILES} repository modules`);
|
|
501
|
-
const dep = (0,haskell_parser/* parseHaskell */.cX)(local.text);
|
|
502
|
-
if (!dep || dep.status !== 'parsed') return generate_haskell_fail(`the module ${mod} could not be parsed as Haskell`);
|
|
503
|
-
const h = loadTimeHazard(local.text); if (h) return generate_haskell_fail(`the module ${mod} is refused: ${h}`);
|
|
504
|
-
if (/\{\{[A-Z_]+(?::[a-z0-9_-]{1,32})?\}\}/.test(local.text)) return generate_haskell_fail(`the module ${mod} contains text shaped like a proof placeholder ({{NAME}})`);
|
|
505
|
-
sources[`${mod.replaceAll('.', '/')}.hs`] = external_node_path_.relative(rootReal, local.real);
|
|
506
|
-
const r = visit(dep.imports ?? []); if (r) return r;
|
|
507
|
-
continue;
|
|
508
|
-
}
|
|
509
|
-
const c = classify(mod, imp.package, boot);
|
|
510
|
-
if (!c.ok) external.push(c.name);
|
|
511
|
-
}
|
|
512
|
-
return null;
|
|
513
|
-
};
|
|
514
|
-
const bad = visit(info.imports ?? []);
|
|
515
|
-
if (bad) return bad;
|
|
516
|
-
if (external.length) return generate_haskell_fail(`the flagged code needs ${[...new Set(external)].slice(0, 4).join(', ')}, which is not available in the proof environment; only the packages that ship with GHC and files in the repository can be loaded`, { missing: [...new Set(external)] });
|
|
517
|
-
sources[entry] = rel;
|
|
518
|
-
|
|
519
|
-
const assumptions = [];
|
|
520
|
-
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 } : {}) };
|
|
521
|
-
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');
|
|
522
|
-
const ghcRun = (expr, overloaded = false) => ['{{GHC}}', '-ignore-dot-ghci', ...(overloaded ? ['-e', ':set -XOverloadedStrings'] : []), '-e', expr, entry];
|
|
523
|
-
const literal = (s) => JSON.stringify(s); // payloads are fixed strings with no quote or backslash; this is a plain Haskell string literal
|
|
524
|
-
const resolver = typeResolver(info, src.text);
|
|
525
|
-
|
|
526
|
-
// what the class plants beside the program
|
|
527
|
-
const extras = { files: {}, build: undefined, env: undefined };
|
|
528
|
-
if (kind === 'path-traversal-read') {
|
|
529
|
-
const dirs = readBaseDirs(src.text);
|
|
530
|
-
if (!dirs.length) return generate_haskell_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`);
|
|
531
|
-
extras.files = { [READ_SECRET_FILE]: '{{SECRET:file}}', ...Object.fromEntries(dirs.map((d) => [`${d}/.keep`, ''])) };
|
|
532
|
-
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)`);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// shape 1: a program that reads its payload from the command line
|
|
536
|
-
const hasMain = (info.functions ?? []).some((f) => f.scope === 'top' && f.name === 'main');
|
|
537
|
-
if (/\bgetArgs\b/.test(src.text) && hasMain) {
|
|
538
|
-
if (kind === 'argument-injection') {
|
|
539
|
-
const call = argumentCall(lines, finding);
|
|
540
|
-
if (!call.ok) return call;
|
|
541
|
-
if (call.first.kind !== 'list' || !new RegExp(`\\b${call.first.name}\\s*<-\\s*getArgs\\b`).test(src.text)) return generate_haskell_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\`)`);
|
|
542
|
-
Object.assign(extras, standIn(call.exe));
|
|
543
|
-
assumptions.push(...standInAssumptions(call.exe));
|
|
544
|
-
}
|
|
545
|
-
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');
|
|
546
|
-
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)}`) };
|
|
547
|
-
const errs = (0,proof_fabric_reproducer/* validateReproducer */.mC)(reproducer);
|
|
548
|
-
return errs.length ? generate_haskell_fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'script', class: spec.name, entry: rel, line: finding.line, assumptions } };
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// shape 2: a top-level function around the flagged line
|
|
552
|
-
const fn = (info.functions ?? []).filter((f) => f.scope === 'top' && f.name !== 'main' && /^[a-z_][\w']*$/.test(f.name)).find((f) => {
|
|
553
|
-
const end = Math.max(f.span.endLine, ...(f.clauses ?? []).map((c) => c.span?.endLine ?? 0));
|
|
554
|
-
return f.span.startLine <= finding.line && end >= finding.line;
|
|
555
|
-
});
|
|
556
|
-
if (!fn) return generate_haskell_fail('the flagged line is not inside a top-level function, and the file does not read getArgs in a main');
|
|
557
|
-
const sig = (info.signatures ?? []).find((s) => s.scope === 'top' && s.names.includes(fn.name));
|
|
558
|
-
if (!sig) return generate_haskell_fail(`${fn.name} has no type signature, so the proof cannot tell what to pass it`);
|
|
559
|
-
const sigText = lines.slice(sig.span.startLine - 1, sig.span.endLine).join(' ');
|
|
560
|
-
const m = new RegExp(`${fn.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^:]*::\\s*([\\s\\S]*)$`).exec(sigText);
|
|
561
|
-
if (!m) return generate_haskell_fail(`the type signature of ${fn.name} could not be read`);
|
|
562
|
-
const type = m[1].replace(/--.*$/, '').trim();
|
|
563
|
-
if (type.includes('=>') || /\bforall\b/.test(type)) return generate_haskell_fail(`${fn.name} is polymorphic or constrained (${type.slice(0, 60)}), so the proof cannot choose its arguments`);
|
|
564
|
-
const parts = splitArrows(type);
|
|
565
|
-
const result = parts[parts.length - 1]; const params = parts.slice(0, -1);
|
|
566
|
-
if (!/^IO\b/.test(result)) return generate_haskell_fail(`${fn.name} returns ${result.slice(0, 40)}, not IO, so calling it would not perform the flagged operation`);
|
|
567
|
-
if (!params.length) return generate_haskell_fail(`${fn.name} takes no parameters, so the payload has no way in`);
|
|
568
|
-
const resultInner = result.replace(/^IO\s*/, '').trim();
|
|
569
|
-
if (kind === 'path-traversal-read' && resultInner !== '()' && !resolver.printable(resultInner)) return generate_haskell_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`);
|
|
570
|
-
|
|
571
|
-
let payloadFor = () => spec.payload; // which parameters get the payload (all, unless the class says which)
|
|
572
|
-
if (kind === 'argument-injection') {
|
|
573
|
-
const call = argumentCall(lines, finding);
|
|
574
|
-
if (!call.ok) return call;
|
|
575
|
-
const headLine = lines[fn.span.startLine - 1] ?? '';
|
|
576
|
-
const head = new RegExp(`^${fn.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}((?:\\s+[a-z_][\\w']*)*)\\s*(?:=|\\|)`).exec(headLine);
|
|
577
|
-
const names = head ? head[1].trim().split(/\s+/).filter(Boolean) : null;
|
|
578
|
-
if (!names || names.length !== params.length) return generate_haskell_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`);
|
|
579
|
-
if (call.first.kind !== 'element' && call.first.kind !== 'list') return generate_haskell_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`);
|
|
580
|
-
const at = names.indexOf(call.first.name);
|
|
581
|
-
if (at < 0) return generate_haskell_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`);
|
|
582
|
-
const shape = resolver.fill(params[at], 'x');
|
|
583
|
-
if (!shape) return generate_haskell_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`);
|
|
584
|
-
if (call.first.kind === 'list' && !/^\[/.test(shape.expr)) return generate_haskell_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`);
|
|
585
|
-
if (call.first.kind === 'element' && /^\[/.test(shape.expr)) return generate_haskell_fail(`argument-injection: ${call.first.name} is a list but stands as one element of the argument vector`);
|
|
586
|
-
payloadFor = (i) => (i === at ? spec.payload : 'benign');
|
|
587
|
-
Object.assign(extras, standIn(call.exe));
|
|
588
|
-
assumptions.push(...standInAssumptions(call.exe), `the payload is placed in the parameter \`${call.first.name}\` only; the other parameters get the value "benign"`);
|
|
589
|
-
} 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');
|
|
590
|
-
|
|
591
|
-
const filled = [];
|
|
592
|
-
let overloaded = false;
|
|
593
|
-
for (let i = 0; i < params.length; i++) {
|
|
594
|
-
const f = resolver.fill(params[i], literal(payloadFor(i)));
|
|
595
|
-
if (!f) return generate_haskell_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`);
|
|
596
|
-
filled.push(f.expr); overloaded ||= f.overloaded;
|
|
597
|
-
}
|
|
598
|
-
if (overloaded) assumptions.push('Text and ByteString arguments are written as string literals under OverloadedStrings, set for the call only, not for the program');
|
|
599
|
-
const tail = kind === 'path-traversal-read' && resultInner !== '()' ? '>>= print' : '>>= \\_ -> return ()';
|
|
600
|
-
const call = `${fn.name} ${filled.join(' ')} ${tail}`;
|
|
601
|
-
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) };
|
|
602
|
-
const errs = (0,proof_fabric_reproducer/* validateReproducer */.mC)(reproducer);
|
|
603
|
-
return errs.length ? generate_haskell_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 } };
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
function describeFirst(first) {
|
|
607
|
-
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';
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/** Find the process call at the flagged line and what stands first in its argument vector. */
|
|
611
|
-
function argumentCall(lines, finding) {
|
|
612
|
-
const window = lines.slice(Math.max(0, finding.line - 1), finding.line + 2).join(' ').replace(/\s+/g, ' ');
|
|
613
|
-
const m = PROCESS_CALL.exec(window);
|
|
614
|
-
if (!m) return generate_haskell_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');
|
|
615
|
-
const exe = m[2];
|
|
616
|
-
if (NEVER_STAND_IN.has(exe) || !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$/.test(exe)) return generate_haskell_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)`);
|
|
617
|
-
const rest = window.slice(m.index + m[0].length).trimStart();
|
|
618
|
-
const first = firstArgument(rest);
|
|
619
|
-
if (first.kind === 'literal' || first.kind === 'empty') return generate_haskell_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)`);
|
|
620
|
-
return { ok: true, exe, first };
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/** The stand-in program, its permission and the PATH that finds it. */
|
|
624
|
-
function standIn(exe) {
|
|
625
|
-
return { files: { [`bin/${exe}`]: STAND_IN }, build: ['/bin/chmod', '755', `bin/${exe}`], env: { PATH: STAND_IN_PATH } };
|
|
626
|
-
}
|
|
627
|
-
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`];
|
|
628
|
-
|
|
171
|
+
// EXTERNAL MODULE: ./src/posture/proof-fabric/generate-haskell.js
|
|
172
|
+
var generate_haskell = __webpack_require__(29926);
|
|
629
173
|
;// CONCATENATED MODULE: ./src/posture/proof-fabric/generate.js
|
|
630
174
|
// Reproducer generation from a finding's own evidence (PRD 1: `triage exploit --prove` with no hand-written reproducer).
|
|
631
175
|
//
|
|
@@ -774,12 +318,12 @@ function generateReproducer({ finding, root }) {
|
|
|
774
318
|
const isPy = /\.py$/i.test(rel);
|
|
775
319
|
const isHs = /\.hs$/i.test(rel);
|
|
776
320
|
const cls = CLASSES[cwe] ?? (isHs ? HASKELL_ONLY[cwe] : undefined);
|
|
777
|
-
if (isHs && !cls && Number.isInteger(finding?.line)) return haskellRefusal({ finding, root, cwe });
|
|
321
|
+
if (isHs && !cls && Number.isInteger(finding?.line)) return (0,generate_haskell/* haskellRefusal */.Bu)({ finding, root, cwe });
|
|
778
322
|
if (!cls) return generate_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)`);
|
|
779
323
|
if (!isPy && !isHs && !/\.(?:js|mjs|cjs)$/i.test(rel)) return generate_fail(`no harness builder for ${external_node_path_.extname(rel) || 'this file type'}; JavaScript, Python and Haskell only`);
|
|
780
324
|
if (!Number.isInteger(finding?.line)) return generate_fail('the finding has no line number');
|
|
781
325
|
if (isPy) return generatePythonReproducer({ finding, root, cls });
|
|
782
|
-
if (isHs) return generateHaskellReproducer({ finding, root, cls });
|
|
326
|
+
if (isHs) return (0,generate_haskell/* generateHaskellReproducer */.nD)({ finding, root, cls });
|
|
783
327
|
const src = readSource(root, rel);
|
|
784
328
|
if (!src) return generate_fail('the flagged file is missing, outside the repository, or too large');
|
|
785
329
|
const ast = parseSource(src.text);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.190.0",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"test:lineage": "node --test test/lineage/analysis-completion.test.js test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
|
|
94
94
|
"test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
|
|
95
95
|
"test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
|
|
96
|
-
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
|
96
|
+
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
|
97
97
|
"test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
|
|
98
98
|
"test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-parity-haskell.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
|
|
99
99
|
"test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
|