@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/src/language/witness.js
CHANGED
|
@@ -26,6 +26,7 @@ import path from 'node:path';
|
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import { runConfined, sandboxAvailable } from '../sandbox/index.js';
|
|
28
28
|
import { analyzeNixScripts, SHELL_PLACEHOLDER } from './nix-script-taint.js';
|
|
29
|
+
import { bootModuleSet } from '../posture/proof-fabric/generate-haskell.js';
|
|
29
30
|
|
|
30
31
|
export const WITNESS_VERSION = 'language-witness/1';
|
|
31
32
|
export const PROOF_MARKER = 'PROVEN';
|
|
@@ -185,3 +186,98 @@ export function nixFixWitness(finding, opts = {}) {
|
|
|
185
186
|
return judgeFixWitness(witnessNixFlow(nixOnly(files), target, opts), witnessNixFlow(nixOnly(patched), target, opts));
|
|
186
187
|
};
|
|
187
188
|
}
|
|
189
|
+
|
|
190
|
+
// ── Haskell: the fix witness ──────────────────────────────────────────────────
|
|
191
|
+
// `haskellFixWitness(finding)` is the `witness` hook of `runFixLifecycle` for a Haskell fix, the counterpart of `nixFixWitness`. It runs the ORIGINAL
|
|
192
|
+
// and the PATCHED file under GHC's interpreter inside the confinement sandbox (`ghc -e ':load *File' -e 'fn "<payload>"'`, every top-level name in
|
|
193
|
+
// scope, so the function need not be exported) and judges them with `judgeFixWitness`: verified only when the original reproduced the effect and the
|
|
194
|
+
// patched code ran without reproducing it.
|
|
195
|
+
//
|
|
196
|
+
// Where it can honestly be built: a command-injection fix (`behavior.kind === 'process'`) in a top-level function whose parameters are all
|
|
197
|
+
// String/FilePath and whose result is IO, in a file whose imports are all modules of the packages that ship with GHC (`bootModuleSet`, the same
|
|
198
|
+
// decision the proof fabric makes before it runs anything). The payload makes the shell create `PROVEN`; the marker is the only thing that proves.
|
|
199
|
+
// Every other class is REFUSED with the reason (never faked): the effect is not observable by a program GHC can run alone (a TLS peer, a response
|
|
200
|
+
// header of a web framework, a database), not an exploit at all (a digest, a log line), or a property of a distribution (randomness).
|
|
201
|
+
const WITNESS_REFUSALS = Object.freeze({
|
|
202
|
+
tls: 'certificate validation is observable only against a TLS server presenting an untrusted certificate, and the confinement environment denies the network',
|
|
203
|
+
cookie: 'a cookie attribute is a property of a response header produced by a web framework (wai, scotty, servant) that GHC does not ship, so no program can run it here',
|
|
204
|
+
random: 'unpredictability is a property of a distribution; no single run reproduces or refutes it',
|
|
205
|
+
'body-limit': 'the effect needs a wai Request with a streamed body, and wai is not a package GHC ships',
|
|
206
|
+
sql: 'the effect is observable only in a database driver, which GHC does not ship',
|
|
207
|
+
html: 'the effect is observable only in the rendering library, which GHC does not ship',
|
|
208
|
+
hash: 'a weaker digest is not an exploitable effect a program can demonstrate',
|
|
209
|
+
log: 'the effect is a value appearing in a log, which depends on the logging setup the file does not state',
|
|
210
|
+
path: 'the base directory is the program\'s own and is not present in the sandbox, so a traversal out of it cannot be planted and observed',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/** The top-level function around `line`: {name, params:[{name,type}], result} from its signature and first clause, or null. */
|
|
214
|
+
export function haskellFunctionAt(text, line) {
|
|
215
|
+
const lines = String(text).split('\n');
|
|
216
|
+
let clause = -1;
|
|
217
|
+
for (let i = Math.min(line - 1, lines.length - 1); i >= 0; i--) if (/^[a-z_][\w']*\b[^:\n]*=/.test(lines[i]) && !/^[a-z_][\w']*\s*::/.test(lines[i])) { clause = i; break; }
|
|
218
|
+
if (clause < 0) return null;
|
|
219
|
+
const m = /^([a-z_][\w']*)([^=]*)=/.exec(lines[clause]);
|
|
220
|
+
if (!m) return null;
|
|
221
|
+
const params = m[2].trim() ? m[2].trim().split(/\s+/) : [];
|
|
222
|
+
if (params.some((p) => !/^[a-z_][\w']*$/.test(p))) return null;
|
|
223
|
+
const sigLine = lines.findIndex((l) => new RegExp(`^${m[1]}\\s*::`).test(l));
|
|
224
|
+
if (sigLine < 0) return null;
|
|
225
|
+
let sig = lines[sigLine].replace(/^[^:]*::/, '');
|
|
226
|
+
for (let j = sigLine + 1; j < lines.length && /^\s+\S/.test(lines[j]) && !/^[a-z_][\w']*\s*(::|=)/.test(lines[j]); j++) sig += ` ${lines[j].trim()}`;
|
|
227
|
+
if (/=>/.test(sig)) return null; // a constrained signature: the argument types are not concrete
|
|
228
|
+
const types = []; let depth = 0; let cur = '';
|
|
229
|
+
for (let i = 0; i < sig.length; i++) {
|
|
230
|
+
const c = sig[i];
|
|
231
|
+
if (c === '(' || c === '[') depth++; else if (c === ')' || c === ']') depth--;
|
|
232
|
+
if (depth === 0 && sig.startsWith('->', i)) { types.push(cur.trim()); cur = ''; i++; continue; }
|
|
233
|
+
cur += c;
|
|
234
|
+
}
|
|
235
|
+
types.push(cur.trim());
|
|
236
|
+
if (types.length !== params.length + 1) return null;
|
|
237
|
+
return { name: m[1], params: params.map((p, i) => ({ name: p, type: types[i] })), result: types[types.length - 1] };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Runs `expr` in the interpreter with `file` loaded (all its top-level names in scope), confined. Never throws. */
|
|
241
|
+
export function runHaskellEval(tree, file, expr, { timeoutMs = 60_000, force } = {}) {
|
|
242
|
+
const base = { witness: WITNESS_VERSION, lang: 'haskell', ran: false, backend: null, marker: PROOF_MARKER };
|
|
243
|
+
const ghc = which('ghc');
|
|
244
|
+
if (!ghc) return { ...base, status: 'unavailable', reason: 'no GHC on PATH; the Haskell witness cannot run here' };
|
|
245
|
+
if (!sandboxAvailable()) return { ...base, status: 'invalid', reason: 'no confinement primitive available; refusing to execute' };
|
|
246
|
+
let root;
|
|
247
|
+
try {
|
|
248
|
+
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'witness-hs-')));
|
|
249
|
+
for (const [f, t] of Object.entries(tree)) if (/\.l?hs$/i.test(f) && typeof t === 'string') { fs.mkdirSync(path.dirname(path.join(root, f)), { recursive: true }); fs.writeFileSync(path.join(root, f), t); }
|
|
250
|
+
const r = runConfined([ghc, '-isrc', '-i.', '-e', `:load *${file}`, '-e', expr], { root, timeoutMs, force });
|
|
251
|
+
if (r.status === 'disabled' || r.status === 'error') return { ...base, backend: r.backend, status: 'invalid', reason: `the sandbox could not execute it: ${String(r.stderr || '').trim() || r.status}` };
|
|
252
|
+
if (r.timedOut) return { ...base, backend: r.backend, status: 'invalid', reason: 'the witness exceeded its time budget', timedOut: true };
|
|
253
|
+
const proven = fs.existsSync(path.join(root, PROOF_MARKER));
|
|
254
|
+
if (!proven && /\[GHC-\d+\]|Could not find module|parse error|error:\s/.test(String(r.stderr || ''))) return { ...base, backend: r.backend, status: 'invalid', reason: `the module did not load or run: ${String(r.stderr).trim().split('\n').find((l) => l.trim()) || 'GHC rejected it'}` };
|
|
255
|
+
return { ...base, ran: true, backend: r.backend, status: proven ? 'reproduced' : 'not-reproduced', exitCode: r.exitCode, reason: proven ? null : 'the function ran and the marker was not created' };
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return { ...base, status: 'invalid', reason: `witness harness error: ${String((e && e.message) || e)}` };
|
|
258
|
+
} finally { if (root) fs.rmSync(root, { recursive: true, force: true }); }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const importsOf = (text) => [...String(text).matchAll(/^import\s+(?:safe\s+)?(?:qualified\s+)?([A-Z][\w.']*)/gm)].map((m) => m[1]);
|
|
262
|
+
|
|
263
|
+
/** A `witness` hook for runFixLifecycle over a Haskell fix. See the block comment above for what is and is not supported. */
|
|
264
|
+
export function haskellFixWitness(finding, opts = {}) {
|
|
265
|
+
return (plan, files, patched) => {
|
|
266
|
+
const kind = plan && plan.behavior && plan.behavior.kind;
|
|
267
|
+
const no = (reason) => ({ ok: false, verified: false, reason });
|
|
268
|
+
if (kind !== 'process') return no(`no controlled witness can be built for ${kind || 'this'} fixes: ${WITNESS_REFUSALS[kind] || 'the class has no observable effect a program GHC runs alone could produce'}`);
|
|
269
|
+
const fn = haskellFunctionAt(plan.before, plan.fixedAtLine || finding.line);
|
|
270
|
+
if (!fn || !fn.params.length || !/^IO\b/.test(fn.result) || fn.params.some((p) => !/^(?:String|FilePath)$/.test(p.type))) return no('the flagged call is not in a top-level function of String/FilePath parameters returning IO, so there is no argument to put a payload in');
|
|
271
|
+
const ghc = opts.ghc || which('ghc');
|
|
272
|
+
if (!ghc) return { ok: false, verified: false, status: 'unavailable', reason: 'no GHC on PATH; the Haskell witness is unavailable here' };
|
|
273
|
+
const boot = bootModuleSet(ghc);
|
|
274
|
+
if (!boot) return no('the modules the installed GHC provides could not be listed, so whether the file can be loaded is unknown');
|
|
275
|
+
const external = [...new Set([...importsOf(plan.before), ...importsOf(plan.after)])].filter((m) => !boot.has(m));
|
|
276
|
+
if (external.length) return no(`the file imports ${external.slice(0, 4).join(', ')}, which GHC's boot packages do not provide, so it cannot be loaded for a witness`);
|
|
277
|
+
if (/\{-#\s*LANGUAGE[^#]*\b(?:TemplateHaskell|QuasiQuotes|CPP)\b/.test(plan.before)) return no('the file uses Template Haskell, quasi-quotes or CPP, which run code at load time');
|
|
278
|
+
const payload = `x; touch ${PROOF_MARKER}`;
|
|
279
|
+
const expr = `${fn.name} ${fn.params.map(() => JSON.stringify(payload)).join(' ')}`;
|
|
280
|
+
const only = (t) => Object.fromEntries(Object.entries(t).filter(([p, x]) => p === plan.file && typeof x === 'string'));
|
|
281
|
+
return judgeFixWitness(runHaskellEval(only(files), plan.file, expr, opts), runHaskellEval(only(patched), plan.file, expr, opts));
|
|
282
|
+
};
|
|
283
|
+
}
|
package/src/mcp/tools.js
CHANGED
|
@@ -1219,7 +1219,7 @@ export const synthesize_fix = {
|
|
|
1219
1219
|
if (lc.languageOfFinding(f)) {
|
|
1220
1220
|
const proj = lc.loadLanguageProject(ctx.sessionRoot);
|
|
1221
1221
|
const prev = await lc.languageFixPreview(f, proj.files);
|
|
1222
|
-
languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
|
|
1222
|
+
languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, decision: prev.decision || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
|
|
1223
1223
|
// A fix that spans several files is applied with apply_fix + plan_digest (atomic, one history group); see _applyMultiFilePlan.
|
|
1224
1224
|
if (prev.ok && prev.edits) {
|
|
1225
1225
|
languageFix.multiFile = prev.edits.map((e) => e.file);
|