@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.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 +38 -0
- package/bin/agentic-security.js +11 -2
- package/dist/1919.index.js +1 -1
- package/dist/2432.index.js +10 -1
- package/dist/4113.index.js +60 -9
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7033.index.js +4 -1
- package/dist/7838.index.js +39 -3
- package/dist/8714.index.js +1 -1
- package/dist/8798.index.js +2 -2
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +205 -62
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -9
- package/src/dataflow/engine.js +20 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +29 -8
- package/src/fix/apply-fix-service.js +33 -3
- package/src/language/CLAUDE.md +6 -1
- package/src/language/aibom.js +4 -1
- package/src/language/fix-lifecycle.js +9 -0
- package/src/language/haskell-ir.js +47 -7
- package/src/language/haskell-sca.js +87 -7
- package/src/language/haskell-supply.js +175 -6
- package/src/language/haskell-web.js +200 -18
- package/src/language/nix-fix.js +1 -1
- package/src/language/resolved-pass.js +4 -1
- package/src/language/witness.js +1 -1
- package/src/mcp/CLAUDE.md +43 -4
- package/src/mcp/tools.js +185 -43
- package/src/posture/CLAUDE.md +174 -0
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/blast-radius.js +15 -1
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/oracles/adapters.js +360 -0
- package/src/posture/oracles/conformance.js +275 -0
- package/src/posture/oracles/oracle.js +433 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/remediation-ledger.js +10 -1
- package/src/posture/replay/replay.js +249 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +214 -0
- package/src/posture/verification/patch-promotion.js +105 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +49 -0
- package/src/sandbox/CLAUDE.md +41 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +68 -29
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
- package/src/sast/pipeline.js +51 -4
|
@@ -90,6 +90,7 @@ class Ctx {
|
|
|
90
90
|
this.fnByQid = new Map();
|
|
91
91
|
for (const f of Object.values(this.ir.perFile)) for (const fn of f.functions) this.fnByQid.set(fn.qid, fn);
|
|
92
92
|
this.guardMemo = new Map();
|
|
93
|
+
this.handlerAst = new Map(); // handler IR fn -> { mod, rhss } for handlers lowered from an expression (named top-level ones are found by name)
|
|
93
94
|
this.gaps = [];
|
|
94
95
|
this.limits = [];
|
|
95
96
|
}
|
|
@@ -271,6 +272,17 @@ function kidsIr(e) {
|
|
|
271
272
|
function* walkIr(e, depth = 0) { if (!e || typeof e !== 'object' || depth > 80) return; yield e; for (const c of kidsIr(e)) yield* walkIr(c, depth + 1); }
|
|
272
273
|
const identsIr = (e) => { const out = new Set(); for (const x of walkIr(e)) { if (x.kind === 'ident' && x.name) out.add(x.name); if (x.kind === 'member' && x.object) { for (const y of walkIr(x.object)) if (y.kind === 'ident') out.add(y.name); } } return out; };
|
|
273
274
|
|
|
275
|
+
function isSensitiveCall(ctx, c) {
|
|
276
|
+
if (SENSITIVE_PREFIX.some((p) => c.callee.startsWith(p))) return true;
|
|
277
|
+
const bare = c.callee.split('.').pop();
|
|
278
|
+
if (PERSIST_FNS.has(bare) && !/^(?:Prelude|Data|Web)\./.test(c.callee)) return true;
|
|
279
|
+
if (c.hs && c.hs.status === 'resolved' && c.hs.target) {
|
|
280
|
+
const sub = ctx.fnByQid.get(c.hs.target);
|
|
281
|
+
if (sub && callsOf(sub).some((x) => SENSITIVE_PREFIX.some((p) => (x.callee || '').startsWith(p)))) return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
274
286
|
// ── handler summary ─────────────────────────────────────────────────────────
|
|
275
287
|
// Ordered call list (program order) + principal / id / ownership facts for one handler function IR.
|
|
276
288
|
function handlerFacts(ctx, fn, opts = {}) {
|
|
@@ -294,16 +306,7 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
294
306
|
if (c.hs && c.hs.status === 'resolved' && c.hs.target) return guardSummary(ctx, ctx.fnByQid.get(c.hs.target)).auth;
|
|
295
307
|
return false;
|
|
296
308
|
};
|
|
297
|
-
const isSensitive = (c) =>
|
|
298
|
-
if (SENSITIVE_PREFIX.some((p) => c.callee.startsWith(p))) return true;
|
|
299
|
-
const bare = c.callee.split('.').pop();
|
|
300
|
-
if (PERSIST_FNS.has(bare) && !/^(?:Prelude|Data|Web)\./.test(c.callee)) return true;
|
|
301
|
-
if (c.hs && c.hs.status === 'resolved' && c.hs.target) {
|
|
302
|
-
const sub = ctx.fnByQid.get(c.hs.target);
|
|
303
|
-
if (sub && callsOf(sub).some((x) => SENSITIVE_PREFIX.some((p) => (x.callee || '').startsWith(p)))) return true;
|
|
304
|
-
}
|
|
305
|
-
return false;
|
|
306
|
-
};
|
|
309
|
+
const isSensitive = (c) => isSensitiveCall(ctx, c);
|
|
307
310
|
const guardIdx = ordered.findIndex((o) => isGuardCall(o.call));
|
|
308
311
|
const sensIdx = ordered.findIndex((o) => isSensitive(o.call));
|
|
309
312
|
// An INLINE guard: the handler itself reads a credential and, before any sensitive operation, rejects. Scotty's `status` only sets the
|
|
@@ -327,7 +330,7 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
327
330
|
if (first !== null) reads.push({ line: first, kind: cp.kind, callee: `param:${cp.name}`, call: null });
|
|
328
331
|
}
|
|
329
332
|
reads.sort((x, y) => x.line - y.line);
|
|
330
|
-
let inlineIdx = -1, inlineKind = null, inlineLine = null, inlineCallee = null;
|
|
333
|
+
let inlineIdx = -1, inlineKind = null, inlineLine = null, inlineCallee = null, inlineRejectCallee = null;
|
|
331
334
|
for (let i = 0; i < reads.length && inlineIdx < 0; i++) {
|
|
332
335
|
const rd = reads[i];
|
|
333
336
|
if (!(rd.line <= sensLine)) break;
|
|
@@ -337,14 +340,14 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
337
340
|
const r = allCalls[j];
|
|
338
341
|
if (r === rd.call || r.line < rd.line || !isRejection(r) || !stopsHandler(r, allCalls)) continue;
|
|
339
342
|
if (SCOTTY_STATUS.test(r.callee) && !haltsAfter(rd.line)) continue; // an identifier carries its enclosing node's line, so position is judged from the credential read
|
|
340
|
-
inlineIdx = j; inlineKind = rd.kind; inlineLine = r.line; inlineCallee = rd.callee; break;
|
|
343
|
+
inlineIdx = j; inlineKind = rd.kind; inlineLine = r.line; inlineCallee = rd.callee; inlineRejectCallee = r.callee; break;
|
|
341
344
|
}
|
|
342
345
|
if (inlineIdx >= 0) break;
|
|
343
346
|
// (b) a rejection that ends the handler by itself (WAI response handed back, throwError, permissionDenied, raiseStatus). A call in tail position
|
|
344
347
|
// (the last statement, a case alternative) reports the enclosing line, not its own, so its line cannot be ordered against the sensitive
|
|
345
348
|
// operation; the ordering that CAN be trusted is the credential read against the first sensitive operation, tested above.
|
|
346
349
|
const hard = allCalls.findIndex((r) => r !== rd.call && isRejection(r) && !SCOTTY_STATUS.test(r.callee) && stopsHandler(r, allCalls));
|
|
347
|
-
if (hard >= 0) { inlineIdx = hard; inlineKind = rd.kind; inlineLine = allCalls[hard].line; inlineCallee = rd.callee; break; }
|
|
350
|
+
if (hard >= 0) { inlineIdx = hard; inlineKind = rd.kind; inlineLine = allCalls[hard].line; inlineCallee = rd.callee; inlineRejectCallee = allCalls[hard].callee; break; }
|
|
348
351
|
}
|
|
349
352
|
const principals = new Set();
|
|
350
353
|
for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
|
|
@@ -357,7 +360,7 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
357
360
|
if (nm && /id$/i.test(nm)) idVars.add(a.target);
|
|
358
361
|
}
|
|
359
362
|
}
|
|
360
|
-
return { ordered, guardIdx, inlineIdx, inlineKind, inlineLine, inlineCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
|
|
363
|
+
return { ordered, guardIdx, inlineIdx, inlineKind, inlineLine, inlineCallee, inlineRejectCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
|
|
361
364
|
}
|
|
362
365
|
|
|
363
366
|
function hasOwnership(ctx, facts) {
|
|
@@ -378,6 +381,181 @@ function hasOwnership(ctx, facts) {
|
|
|
378
381
|
return null;
|
|
379
382
|
}
|
|
380
383
|
|
|
384
|
+
// ── structural order of an inline guard (WAI, Servant, Yesod) ──────────────────
|
|
385
|
+
// For these frameworks the IR reports a call in tail position (the last statement, a case alternative) at the ENCLOSING line, so a line
|
|
386
|
+
// number cannot say whether the rejection runs before the first sensitive operation. The handler's own syntax tree can: this walks it in
|
|
387
|
+
// evaluation order (statements of a do block in sequence, a scrutinee or condition before its branches, `when`/`unless`/`if`/`case` as forks)
|
|
388
|
+
// and proves, per sensitive operation, whether a rejection that follows a credential read has already been passed on every path to it.
|
|
389
|
+
//
|
|
390
|
+
// * A branch is REJECTING when every path through it ends in a rejection reached after a credential read. The sibling branches of a rejecting
|
|
391
|
+
// branch, and everything after the conditional, are GUARDED. This is a statement about control flow only: it does not judge that the
|
|
392
|
+
// condition really examines the credential (the same laxity the line-based check had).
|
|
393
|
+
// * `throwError`, `permissionDenied` and `notAuthenticated` stop the handler wherever they appear. A WAI `respond`/`return` of a 401 stops it only in
|
|
394
|
+
// TAIL position (the value the handler hands back); a mid-block `respond` is followed by the next statement, so it is not a rejection.
|
|
395
|
+
// * Anything not understood (a rejection mixed with a sensitive operation inside one opaque expression, a body that is not available as
|
|
396
|
+
// source, a sensitive call the walk did not find) makes the order UNKNOWN, and unknown is reported as late, never as authenticated.
|
|
397
|
+
// Leaf expressions are classified by lowering them alone and reusing the classifiers above (credentialReadsOf, isRejection, stopsHandler,
|
|
398
|
+
// isSensitiveCall), so this adds ordering and no second definition of what a credential, a rejection or a sensitive call is.
|
|
399
|
+
const HARD_REJECT_HEAD = /^(?:throwError|permissionDenied|notAuthenticated)$/;
|
|
400
|
+
const SOFT_REJECT_HEAD = /^(?:respond|return|pure)$/;
|
|
401
|
+
|
|
402
|
+
function rhsBranches(rhs) {
|
|
403
|
+
if (!rhs) return [];
|
|
404
|
+
if (Array.isArray(rhs.guards) && rhs.guards.length) return rhs.guards.map((g) => ({ quals: g.quals || [], body: g.body }));
|
|
405
|
+
return [{ quals: [], body: rhs.body }];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function handlerSource(ctx, fn) {
|
|
409
|
+
const reg = ctx.handlerAst.get(fn);
|
|
410
|
+
if (reg) return reg;
|
|
411
|
+
const mod = ctx.modOf(fn.file);
|
|
412
|
+
const fun = mod && mod.groups.funs.find((f) => `${mod.name}.${f.name}` === fn.name);
|
|
413
|
+
return fun ? { mod, rhss: fun.clauses.map((c) => c.rhs) } : null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function inlineOrder(ctx, fn, facts, opts) {
|
|
417
|
+
const src = handlerSource(ctx, fn);
|
|
418
|
+
if (!src) return { verdict: 'unknown', reason: 'the handler body is not available as source structure' };
|
|
419
|
+
const credNames = new Set((opts.credentialParams || []).map((c) => c.name));
|
|
420
|
+
const irSens = callsOf(fn).filter((c) => typeof c.callee === 'string' && isSensitiveCall(ctx, c)).length;
|
|
421
|
+
const T = { astSens: 0, failed: false, unordered: false };
|
|
422
|
+
const memo = new Map();
|
|
423
|
+
const localSens = new Map(); // a name bound by let/where to an expression that performs a sensitive operation -> how many
|
|
424
|
+
let seq = 0;
|
|
425
|
+
const mentions = (e, names) => { for (const x of walkAst(e)) if (x.t === 'var' && !x.qual && names.has(x.name)) return x.name; return null; };
|
|
426
|
+
const classify = (e) => {
|
|
427
|
+
if (memo.has(e)) return memo.get(e);
|
|
428
|
+
let out = { reads: false, rej: false, sensN: 0 };
|
|
429
|
+
try {
|
|
430
|
+
const f2 = lowerHandlerExpr(ctx.project, src.mod, e, `order${seq++}`, Number.isFinite(e.line) ? e.line : 1);
|
|
431
|
+
const calls = [...callsOf(f2), ...nullaryRejectionsOf(f2)].filter((c) => typeof c.callee === 'string');
|
|
432
|
+
out = {
|
|
433
|
+
// an argument-less credential read (`maybeAuthId`, `lookupBearerAuth`) is an identifier, not a call, when it is lowered on its own
|
|
434
|
+
reads: credentialReadsOf(f2).size > 0 || Object.values(f2.cfg.nodes).some((n) => exprsOfNode(n).some((r) => [...walkIr(r)].some((x) => x.kind === 'ident' && typeof x.name === 'string' && !!credentialKind({ callee: x.name, args: [] })))),
|
|
435
|
+
rej: calls.some((c) => isRejection(c) && !SCOTTY_STATUS.test(c.callee) && stopsHandler(c, calls)),
|
|
436
|
+
sensN: callsOf(f2).filter((c) => typeof c.callee === 'string' && isSensitiveCall(ctx, c)).length,
|
|
437
|
+
};
|
|
438
|
+
} catch { T.failed = true; }
|
|
439
|
+
// A lambda inside the expression is lifted out by the lowering, so the calls in its body are not among the expression's own: classify
|
|
440
|
+
// each lambda body on its own and fold it in (over-counting is the safe direction here).
|
|
441
|
+
memo.set(e, out);
|
|
442
|
+
for (const x of walkAst(e)) {
|
|
443
|
+
const bodies = x.t === 'lam' ? [x.body] : x.t === 'lamcase' ? (x.alts || []).flatMap((a) => rhsBranches(a.rhs).map((b) => b.body)) : [];
|
|
444
|
+
for (const b of bodies) { const sub = classify(b); out = { reads: out.reads || sub.reads, rej: out.rej || sub.rej, sensN: out.sensN + sub.sensN }; }
|
|
445
|
+
}
|
|
446
|
+
T.astSens += out.sensN;
|
|
447
|
+
memo.set(e, out);
|
|
448
|
+
return out;
|
|
449
|
+
};
|
|
450
|
+
const newAcc = () => ({ sens: [], guards: 0 });
|
|
451
|
+
const localMentions = (e) => { const out = []; for (const x of walkAst(e)) if (x.t === 'var' && !x.qual && localSens.has(x.name)) out.push(x.name); return out; };
|
|
452
|
+
|
|
453
|
+
const atom = (e, st, tail, acc) => {
|
|
454
|
+
const c = classify(e);
|
|
455
|
+
let s = st;
|
|
456
|
+
if (c.reads || (credNames.size && mentions(e, credNames))) s = { ...s, read: true, readMay: true };
|
|
457
|
+
let n = c.sensN;
|
|
458
|
+
for (const nm of localMentions(e)) n += localSens.get(nm);
|
|
459
|
+
for (let i = 0; i < n; i++) acc.sens.push({ guarded: s.guarded, readMay: s.readMay });
|
|
460
|
+
const sp = spine(e);
|
|
461
|
+
const hn = sp && sp.head ? sp.head.name : null;
|
|
462
|
+
if (c.rej && n === 0 && hn && (HARD_REJECT_HEAD.test(hn) || (tail && SOFT_REJECT_HEAD.test(hn))) && s.read) { acc.guards++; return { st: s, term: true }; }
|
|
463
|
+
if (c.rej && n > 0) T.unordered = true;
|
|
464
|
+
return { st: s, term: false };
|
|
465
|
+
};
|
|
466
|
+
// Run alternative branches. A branch that always rejects (after a read) guards its siblings and everything after the fork.
|
|
467
|
+
const fork = (st, kids, acc) => {
|
|
468
|
+
const rejecting = kids.map((k) => k(st, newAcc()).term);
|
|
469
|
+
const base = rejecting.some(Boolean) ? { ...st, guarded: true } : st;
|
|
470
|
+
const outs = kids.map((k, i) => k(rejecting[i] ? st : base, acc));
|
|
471
|
+
const live = outs.filter((o) => !o.term);
|
|
472
|
+
if (!live.length) return { st, term: true };
|
|
473
|
+
return { st: { read: live.every((o) => o.st.read), readMay: st.readMay || live.some((o) => o.st.readMay), guarded: live.every((o) => o.st.guarded) }, term: false };
|
|
474
|
+
};
|
|
475
|
+
const letDecls = (decls, st) => {
|
|
476
|
+
let s = st;
|
|
477
|
+
for (const d of decls || []) {
|
|
478
|
+
const rhs = d && d.t === 'clause' ? d.clause && d.clause.rhs : d && d.t === 'patbind' ? d.rhs : null;
|
|
479
|
+
if (!rhs) continue;
|
|
480
|
+
for (const b of rhsBranches(rhs)) {
|
|
481
|
+
const c = classify(b.body);
|
|
482
|
+
if (c.reads || (credNames.size && mentions(b.body, credNames))) s = { ...s, read: true, readMay: true };
|
|
483
|
+
if (c.sensN && d.t === 'clause' && d.name) localSens.set(d.name, (localSens.get(d.name) || 0) + c.sensN);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return s;
|
|
487
|
+
};
|
|
488
|
+
const walkBranch = (b, st, tail, acc) => {
|
|
489
|
+
let s = st;
|
|
490
|
+
for (const q of b.quals || []) {
|
|
491
|
+
if (q.t === 'qlet') { s = letDecls(q.decls, s); continue; }
|
|
492
|
+
const r = walk(q.e, s, false, acc);
|
|
493
|
+
if (r.term) return r;
|
|
494
|
+
s = r.st;
|
|
495
|
+
}
|
|
496
|
+
return walk(b.body, s, tail, acc);
|
|
497
|
+
};
|
|
498
|
+
const altKids = (alts, tail) => (alts || []).flatMap((alt) => rhsBranches(alt.rhs).map((b) => (s, a) => walkBranch(b, s, tail, a)));
|
|
499
|
+
const walkStmts = (stmts, st, tail, acc) => {
|
|
500
|
+
let s = st;
|
|
501
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
502
|
+
const stmt = stmts[i];
|
|
503
|
+
const t = tail && i === stmts.length - 1;
|
|
504
|
+
let r;
|
|
505
|
+
if (stmt.t === 'sexpr') r = walk(stmt.e, s, t, acc);
|
|
506
|
+
else if (stmt.t === 'sbind') r = walk(stmt.e, s, false, acc);
|
|
507
|
+
else if (stmt.t === 'slet') r = { st: letDecls(stmt.decls, s), term: false };
|
|
508
|
+
else { T.unordered = true; r = { st: s, term: false }; }
|
|
509
|
+
if (r.term) return r;
|
|
510
|
+
s = r.st;
|
|
511
|
+
}
|
|
512
|
+
return { st: s, term: false };
|
|
513
|
+
};
|
|
514
|
+
const walk = (e0, st, tail, acc) => {
|
|
515
|
+
const e = unparen(e0);
|
|
516
|
+
if (!e) return { st, term: false };
|
|
517
|
+
switch (e.t) {
|
|
518
|
+
case 'do': return walkStmts(e.stmts || [], st, tail, acc);
|
|
519
|
+
case 'case': { const r = walk(e.scrut, st, false, acc); return r.term ? r : fork(r.st, altKids(e.alts, tail), acc); }
|
|
520
|
+
case 'lamcase': return fork(st, altKids(e.alts, tail), acc);
|
|
521
|
+
case 'if': { const r = walk(e.c, st, false, acc); return r.term ? r : fork(r.st, [(s, a) => walk(e.a, s, tail, a), (s, a) => walk(e.b, s, tail, a)], acc); }
|
|
522
|
+
case 'multiif': return fork(st, (e.guards || []).map((g) => (s, a) => walkBranch({ quals: g.quals || [], body: g.body }, s, tail, a)), acc);
|
|
523
|
+
case 'lam': return walk(e.body, st, tail, acc);
|
|
524
|
+
case 'let': return walk(e.body, letDecls(e.decls, st), tail, acc);
|
|
525
|
+
default: break;
|
|
526
|
+
}
|
|
527
|
+
if (e.t === 'app' || (e.t === 'op' && e.op === '$')) {
|
|
528
|
+
const sp = spine(e);
|
|
529
|
+
if (sp && sp.head && /^(?:when|unless)$/.test(sp.head.name) && sp.args.length >= 2) {
|
|
530
|
+
const r = walk(sp.args[0], st, false, acc);
|
|
531
|
+
return r.term ? r : fork(r.st, [(s, a) => walk(sp.args[1], s, false, a), (s) => ({ st: s, term: false })], acc);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (e.t === 'op' && (e.op === '>>' || e.op === '*>' || e.op === '>>=')) {
|
|
535
|
+
const l = walk(e.l, st, false, acc);
|
|
536
|
+
return l.term ? l : walk(e.r, l.st, tail, acc);
|
|
537
|
+
}
|
|
538
|
+
if (e.t === 'op' && e.op === '=<<') {
|
|
539
|
+
const r = walk(e.r, st, false, acc);
|
|
540
|
+
return r.term ? r : walk(e.l, r.st, tail, acc);
|
|
541
|
+
}
|
|
542
|
+
return atom(e, st, tail, acc);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const acc = newAcc();
|
|
546
|
+
let s0 = { read: false, readMay: false, guarded: false };
|
|
547
|
+
for (const rhs of src.rhss) s0 = letDecls(rhs.where || [], s0);
|
|
548
|
+
fork(s0, src.rhss.flatMap(rhsBranches).map((b) => (s, a) => walkBranch(b, s, true, a)), acc);
|
|
549
|
+
if (T.failed) return { verdict: 'unknown', reason: 'part of the handler could not be analysed' };
|
|
550
|
+
if (T.unordered) return { verdict: 'unknown', reason: 'a rejection and a sensitive operation sit in one expression whose order is not modelled' };
|
|
551
|
+
if (T.astSens < irSens) return { verdict: 'unknown', reason: 'a sensitive operation was not found in the handler source structure' };
|
|
552
|
+
if (acc.sens.length === 0 && irSens === 0) return { verdict: 'before', reason: 'no sensitive operation to order' };
|
|
553
|
+
const unguarded = acc.sens.filter((x) => !x.guarded);
|
|
554
|
+
if (!unguarded.length && acc.guards > 0) return { verdict: 'before' };
|
|
555
|
+
if (unguarded.length && unguarded.every((x) => x.readMay)) return { verdict: 'after', reason: 'a sensitive operation runs after the credential read and before any rejection' };
|
|
556
|
+
return { verdict: 'unknown', reason: 'the order of the rejection and the sensitive operations could not be established' };
|
|
557
|
+
}
|
|
558
|
+
|
|
381
559
|
// ── route record ─────────────────────────────────────────────────────────────
|
|
382
560
|
function newRoute(r) { return { auth: { status: 'unknown', kind: null, evidence: [] }, role: { status: 'none', evidence: [] }, params: [], uploads: false, limits: [], ...r }; }
|
|
383
561
|
|
|
@@ -403,7 +581,11 @@ function analyzeRoute(ctx, route, handlerFn, opts) {
|
|
|
403
581
|
(sub.kinds || []).forEach((k) => R.credentialKinds.add(k));
|
|
404
582
|
}
|
|
405
583
|
} else if (facts.inlineIdx >= 0) {
|
|
406
|
-
|
|
584
|
+
// Scotty reports its halting points reliably. For WAI, Servant and Yesod the candidate (a credential read and a rejection that ends the
|
|
585
|
+
// handler) is only credited once the STRUCTURE of the handler proves the rejection precedes every sensitive operation.
|
|
586
|
+
const ord = R.framework === 'scotty' ? { verdict: 'before' } : inlineOrder(ctx, handlerFn, facts, opts);
|
|
587
|
+
if (ord.verdict === 'before') R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
|
|
588
|
+
else R.auth = { status: 'late', kind: null, evidence: [{ kind: 'late-guard', callee: facts.inlineRejectCallee || facts.inlineCallee, line: facts.inlineLine, order: ord.verdict, reason: ord.reason }] };
|
|
407
589
|
} else R.auth = { status: 'none', kind: null, evidence: [] };
|
|
408
590
|
// role
|
|
409
591
|
const roleCall = facts.ordered.find((o) => ROLE_CALL.test(o.call.callee || ''));
|
|
@@ -730,14 +912,14 @@ export function analyzeHaskellWeb(files, opts = {}) {
|
|
|
730
912
|
// a handler bound in the enclosing `where` (Servant servers usually are)
|
|
731
913
|
const local = (p.whereDecls || []).find((d) => d.name === head.name && !head.qual);
|
|
732
914
|
if (local && local.clause && local.clause.rhs && local.clause.rhs.body && !(local.clause.rhs.guards && local.clause.rhs.guards.length)) {
|
|
733
|
-
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, local.clause.rhs.body, `${p.framework}-${head.name}`, local.line || p.line); handlerName = head.name; paramNames = paramsOf(local.clause); } catch { handlerFn = null; }
|
|
915
|
+
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, local.clause.rhs.body, `${p.framework}-${head.name}`, local.line || p.line); ctx.handlerAst.set(handlerFn, { mod: p.mod, rhss: [local.clause.rhs] }); handlerName = head.name; paramNames = paramsOf(local.clause); } catch { handlerFn = null; }
|
|
734
916
|
} else {
|
|
735
917
|
const q = ctx.qualify(p.mod, head);
|
|
736
918
|
if (q && q.qid) { handlerFn = ctx.fnFor(q); handlerName = q.name; const f = topFun(p.mod, head.name); if (f && f.clauses[0]) paramNames = paramsOf(f.clauses[0]); }
|
|
737
919
|
else if (q) handlerName = q.name;
|
|
738
920
|
}
|
|
739
921
|
} else {
|
|
740
|
-
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, p.handlerAst, `${p.framework}-${p.line}`, p.line); handlerName = '<inline>'; } catch { handlerFn = null; }
|
|
922
|
+
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, p.handlerAst, `${p.framework}-${p.line}`, p.line); ctx.handlerAst.set(handlerFn, { mod: p.mod, rhss: [{ guards: null, body: p.handlerAst, where: [] }] }); handlerName = '<inline>'; } catch { handlerFn = null; }
|
|
741
923
|
}
|
|
742
924
|
}
|
|
743
925
|
R.handler = handlerFn ? { kind: handlerName === '<inline>' ? 'inline' : 'named', name: handlerName } : { kind: 'unknown', name: handlerName };
|
|
@@ -851,7 +1033,7 @@ function emitFindings(ctx, result, yesodAuthz) {
|
|
|
851
1033
|
if (!R.handler || R.handler.kind === 'unknown') continue;
|
|
852
1034
|
const writes = WRITE_METHODS.has(R.method) || R.method === 'ANY';
|
|
853
1035
|
const privileged = ADMIN_PATH.test(R.path) || /admin/i.test(R.handler.name || '');
|
|
854
|
-
if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
|
|
1036
|
+
if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: R.auth.evidence[0].order === 'unknown' ? `the rejection ${R.auth.evidence[0].callee} cannot be shown to run before the first sensitive operation (${R.auth.evidence[0].reason})` : `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
|
|
855
1037
|
else if (R.auth.status === 'none' && (writes || privileged) && R.sensitive !== false) {
|
|
856
1038
|
if (!(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: R.method === 'ANY' ? 'any method' : 'state-changing method' });
|
|
857
1039
|
}
|
package/src/language/nix-fix.js
CHANGED
|
@@ -340,7 +340,7 @@ function makeEffectiveCheck(entry) {
|
|
|
340
340
|
export async function validateNixFix(finding, o) {
|
|
341
341
|
const plan = planNixFix(finding, o.files, o);
|
|
342
342
|
if (!plan.ok) return { status: plan.status || 'unsupported', applied: false, tier: plan.tier, reason: plan.reason, proposal: plan.proposal || null, ...plan };
|
|
343
|
-
const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, effectiveCheck: makeEffectiveCheck(entryOf(o.files, o)), writeFile: o.writeFile, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
|
|
343
|
+
const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, effectiveCheck: makeEffectiveCheck(entryOf(o.files, o)), writeFile: o.writeFile, preWrite: o.preWrite, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
|
|
344
344
|
return { ...res, consequences: plan.consequences || [], tier: res.status === 'blocked' ? TIERS.blocked : plan.tier, explanation: plan.explanation };
|
|
345
345
|
}
|
|
346
346
|
|
|
@@ -69,13 +69,16 @@ export function resolvedHackageComponents(files) {
|
|
|
69
69
|
const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
|
|
70
70
|
const direct = new Set((g.declared || []).map((d) => d.name));
|
|
71
71
|
const components = [];
|
|
72
|
+
// Boot libraries are not dependencies to match, but the plan states the exact version the compiler provides: the compiler-advisory
|
|
73
|
+
// grouping decides a boot-library advisory from it (and from the compiler-id) instead of from a declared range.
|
|
74
|
+
const bootLibraries = (g.units || []).filter((u) => u.origin === 'compiler' && u.name && u.version).map((u) => ({ name: u.name, version: u.version, versionSource: { file: r.file, line: null } }));
|
|
72
75
|
for (const u of g.units) {
|
|
73
76
|
if (u.origin === 'local' || u.origin === 'compiler' || !u.name || !u.version) continue; // the project itself and boot libraries are not dependencies to match
|
|
74
77
|
const scopes = Array.isArray(u.scopes) && u.scopes.length ? u.scopes : ['runtime'];
|
|
75
78
|
const scope = scopes.includes('runtime') ? 'runtime' : scopes[0];
|
|
76
79
|
components.push({ ecosystem: 'hackage', name: u.name, version: u.version, declaredRange: null, resolution: 'plan', scope, engineScope: SCOPE[scope] || 'required', target: null, componentKind: u.componentKind || null, manifest: r.file, line: null, ghcComponent: false, direct: direct.has(u.name), unitId: u.id, origin: u.origin, versionSource: { file: r.file, line: null } });
|
|
77
80
|
}
|
|
78
|
-
return { components, gaps, summary };
|
|
81
|
+
return { components, bootLibraries, compiler: g.compiler || null, gaps, summary };
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
// ── Nix closure ──────────────────────────────────────────────────────────────────
|
package/src/language/witness.js
CHANGED
|
@@ -92,7 +92,7 @@ export function runNixScriptWitness(w, { timeoutMs = DEFAULT_TIMEOUT_MS, force }
|
|
|
92
92
|
// 1. a witness that does not parse proves nothing in either direction
|
|
93
93
|
const syn = runConfined([shellBin, '-n', 'witness.sh'], { root, timeoutMs, force });
|
|
94
94
|
if (syn.status === 'disabled' || syn.status === 'error') return { ...base, backend: syn.backend, status: 'invalid', reason: `the sandbox could not run the syntax check (${syn.backend}): ${String(syn.stderr || '').trim() || 'no detail'}` };
|
|
95
|
-
if (syn.timedOut) return { ...base, backend: syn.backend, status: 'invalid', reason: 'the syntax check exceeded its time budget' };
|
|
95
|
+
if (syn.timedOut) return { ...base, backend: syn.backend, status: 'invalid', reason: 'the syntax check exceeded its time budget', timedOut: true };
|
|
96
96
|
if (syn.exitCode !== 0) return { ...base, backend: syn.backend, status: 'invalid', reason: `syntax error in the generated witness: ${String(syn.stderr || '').trim().split('\n')[0] || 'shell rejected it'}` };
|
|
97
97
|
// 2. the real run
|
|
98
98
|
const r = runConfined([shellBin, 'witness.sh'], { root, timeoutMs, force });
|
package/src/mcp/CLAUDE.md
CHANGED
|
@@ -12,8 +12,8 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
|
|
|
12
12
|
| `find_rule_module` | ✓ | reads `scanner/src/{sast,posture}/` to answer "which file detects CWE-X / family Y" |
|
|
13
13
|
| `lookup_cve` | ✓ | reads local OSV / KEV / EPSS cache; staleness-tiered |
|
|
14
14
|
| `synthesize_fix` | ✓ | reads last-scan; returns the patch text |
|
|
15
|
-
| `verify_fix` | ✗ | re-scans patched files in memory, runs lint + the project test suite + the fix-honesty gate + PoC re-check; does not touch the target project's own files, but appends a record to `.agentic-security/fix-metrics.jsonl` per attempt |
|
|
16
|
-
| `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup) |
|
|
15
|
+
| `verify_fix` | ✗ | re-scans patched files in memory, runs lint + the project test suite + the fix-honesty gate + PoC re-check; does not touch the target project's own files, but appends a record to `.agentic-security/fix-metrics.jsonl` per attempt. Also returns `verificationRecord`, the shared version-1 verification record (X-201; additive, `apply_fix` returns it too): a static re-scan alone is `not-run`, never confirmed |
|
|
16
|
+
| `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup). With `plan_digest` it applies a MULTI-FILE NixOS option fix (see "Multi-file apply_fix" below): all files or none, one backup per file, one history group |
|
|
17
17
|
| `append_scratchpad` | ✗ | writes under `.agentic-security/agent-scratchpad/<agent>/<session>/` only |
|
|
18
18
|
| `read_scratchpad` | ✓ | paginated read of scratchpad files |
|
|
19
19
|
| `append_agents_memory` | ✗ | appends to `.agentic-security/AGENTS.md` continual-learning file |
|
|
@@ -42,6 +42,8 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
|
|
|
42
42
|
| Path-escape refusal | `tools.js::_confine` lexical check before any fs call |
|
|
43
43
|
| Reserved-write paths | `tools.js::RESERVED_WRITE_*` — `.git/`, `.github/`, `.gitlab/`, `.circleci/`, `.buildkite/`, `.agentic-security/`, `node_modules/`, `.terraform/`, `.aws/`, `k8s/`, manifest basenames, `*.tf`, `docker-compose.yml` |
|
|
44
44
|
| HMAC integrity on findings | `posture/integrity.js` — per-install random key at `$XDG_CONFIG_HOME/agentic-security/scan-key`. **Not** hostname-derived. |
|
|
45
|
+
| Multi-file plan binding | `tools.js::_multiFilePlanDigest` / `_applyMultiFilePlan`: `apply_fix` never accepts file content for a multi-file plan; it recomputes the plan from the signed finding + live tree and compares a digest (finding id, stableId, every path, SHA-256 of every pre/post image) with the caller's `plan_digest`. The lifecycle's `preWrite` hook re-checks the digest of the VERIFIED edits and the targets immediately before the first byte is written |
|
|
46
|
+
| Multi-file confinement | `tools.js::_checkMultiFileTargets`: EVERY file of the plan gets clean root-relative form (no absolute, `..`, `.`, backslash, NUL, empty segment), `_confine` (no symlink leaf, no escape), a no-symlinked-parent check (realpath must equal the lexical path), the reserved-write list, existence, no duplicates, at most 8 files. One bad file refuses the whole plan |
|
|
45
47
|
| Patches pass through unredacted | `tools.js` synthesize_fix / apply_fix — premortem-derived. Patches are not findings; redacting them silently corrupts valid fixes. |
|
|
46
48
|
| Secret redaction on findings | `redact.js` — applied to snippet/description/title/vuln/remediation/trace |
|
|
47
49
|
| Audit log | `audit.js` — NDJSON, hash-chained, at `.agentic-security/mcp-audit.log`. Set `$AGENTIC_SECURITY_AUDIT_WEBHOOK=<url>` to also fire-and-forget POST every entry to a remote witness — closes the full-file-rewrite blind spot. Failures land in `mcp-audit.remote-errors.log` and never block a tool call. |
|
|
@@ -70,5 +72,42 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
|
|
|
70
72
|
|
|
71
73
|
`scan_diff`, `verify_fix` and `synthesize_fix` add the import closure of a Haskell or Nix file and the manifests as context
|
|
72
74
|
(`language/context.js` `withLanguageContext`, bounded in files and bytes, read-only) and still report only the files asked
|
|
73
|
-
about. The tool that previews a fix for these files uses the language fixers
|
|
74
|
-
path)
|
|
75
|
+
about. The tool that previews a fix for these files uses the language fixers. A single-file `apply_fix` is unchanged (its generic
|
|
76
|
+
confined-write path); the language-aware apply with backup and `undo` for everything else is the CLI's `fix --apply`.
|
|
77
|
+
|
|
78
|
+
## Multi-file `apply_fix` (NixOS option fixes, `plan_digest`)
|
|
79
|
+
|
|
80
|
+
A NixOS option is judged on the effective configuration, so one fix can edit several files (every definition at the winning
|
|
81
|
+
priority). Flow: `synthesize_fix` returns `languageFix.multiFile` (the files), `languageFix.edits` (per file: path + SHA-256 of
|
|
82
|
+
the before and after image, **no content**) and `languageFix.planDigest` after running the verification gates read-only. The
|
|
83
|
+
caller then sends `apply_fix {finding_id, confirm:true, plan_digest}` (optionally `dry_run:true`, `fixMeta.approval` for a
|
|
84
|
+
high-impact change).
|
|
85
|
+
|
|
86
|
+
Design decision: the caller **cannot supply file content** on this path (`plan_digest` plus `patch` is refused). The server
|
|
87
|
+
re-derives the plan with `planNixFix` over `loadLanguageProject` and refuses unless the digest matches, so "the plan applied is
|
|
88
|
+
exactly the plan previewed" holds by construction, and a changed project file, a changed finding or an altered digest all give
|
|
89
|
+
`stale:true` with no hint of the expected value. Order of checks: `confirm:true` -> last-scan HMAC -> finding exists -> not shadow
|
|
90
|
+
-> digest format -> Nix finding with stableId -> plan exists and has 2+ files -> digest equal -> every file confined and not
|
|
91
|
+
reserved -> high-impact approval (shared `_highImpactApprovalRefusal`, same code as the `patch` branch) -> lifecycle
|
|
92
|
+
(`validateNixFix`, apply): syntax, rescan (original gone, nothing new at medium+), effective-value gate, then `preWrite`, then
|
|
93
|
+
`writeManyWithBackup` (checks every file unchanged since planning and backs every file up before the first write). History is
|
|
94
|
+
one `languageGroupId` group: `undo` / `undoLast` revert all files or none.
|
|
95
|
+
|
|
96
|
+
Results are non-leaky: absolute paths are scrubbed to `<root>` and passed through `redactString`. A failed write is reported as
|
|
97
|
+
`applied:false` with `rolledBack` and `rollbackIncomplete` (+ `restoreManually` file names) honestly; an incomplete rollback is
|
|
98
|
+
never reported as success.
|
|
99
|
+
|
|
100
|
+
What this deliberately does NOT do: no caller-supplied multi-file `patch` (would reopen the plan-binding problem), no Haskell
|
|
101
|
+
multi-file plans (the Haskell planner produces none), no `flake.lock` relock plans (`.lock` is on the reserved-write list and
|
|
102
|
+
the relock path is not a finding fix), no fix-attempt-budget enforcement (the language lifecycle records attempts but, as in the
|
|
103
|
+
CLI, does not enforce a cap), and no `fixMeta` honesty gate (that gate judges a caller's residual/verdict claims, which this
|
|
104
|
+
path does not take). The `preWrite` digest re-check is defence in depth over a deterministic planner and has no independent
|
|
105
|
+
test that fails when it is removed (the earlier digest comparison is the pinned control).
|
|
106
|
+
|
|
107
|
+
## Verification view (X-206)
|
|
108
|
+
|
|
109
|
+
`explain_finding` (when the finding in `last-scan.json` carries a `verificationRecord`), `verify_fix` and `apply_fix` add
|
|
110
|
+
`verificationView`, the shared projection from `posture/verification/projection.js`, beside the existing `verificationRecord`.
|
|
111
|
+
It is the same object the JSON report, the text reports and the autopilot response carry for the same finding. Additive: a finding
|
|
112
|
+
without a record returns exactly what it did. The key is `verificationView`, not `verification` (that name belongs to the
|
|
113
|
+
producer/verifier separation record). See `docs/guides/verification-schema-migration.md`.
|