@clear-capabilities/agentic-security-scanner 0.186.2 → 0.187.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 +11 -0
- package/dist/1602.index.js +275 -19
- package/dist/9560.index.js +61 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{4d06c8944ae121d7085c.json → deabc47d0e27e21bf99b.json} +4 -4
- package/package.json +4 -4
- package/src/capabilities/decide.js +7 -1
- package/src/dataflow/CLAUDE.md +2 -0
- package/src/dataflow/catalog-haskell.js +7 -1
- package/src/dataflow/engine.js +16 -0
- package/src/dataflow/privacy-catalog-haskell.js +2 -1
- package/src/language/CLAUDE.md +19 -0
- package/src/language/compliance-map.js +6 -0
- package/src/language/context.js +20 -0
- package/src/language/haskell-disclosure.js +16 -4
- package/src/language/haskell-guards.js +130 -3
- package/src/language/haskell-ir.js +182 -10
- package/src/language/haskell-models.js +93 -4
- package/src/language/haskell-security-rules.js +112 -34
- package/src/language/haskell-syntax.js +10 -1
- package/src/language/haskell-web.js +44 -8
- package/src/language/support-registry.js +3 -2
- package/src/mcp/tools.js +54 -3
- package/src/posture/attack-taxonomy.js +10 -0
- package/src/posture/entrypoint-inventory.js +6 -2
- package/src/posture/proof-fabric/generate-haskell.js +266 -17
- package/src/posture/proof-fabric/generate.js +10 -3
- package/src/posture/proof-fabric/oracles.js +21 -0
- package/src/posture/stable-id.js +2 -1
- package/src/report/index.js +3 -0
- package/dist/2066.index.js +0 -851
|
@@ -267,6 +267,7 @@ function kidsIr(e) {
|
|
|
267
267
|
for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') out.push(e[k]);
|
|
268
268
|
for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) out.push(...e[k]);
|
|
269
269
|
if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) out.push(p.value);
|
|
270
|
+
if (e.hs && e.hs.inner && typeof e.hs.inner === 'object') out.push(e.hs.inner); // `oid :: Int`: a number to the taint engine, still the id it names here
|
|
270
271
|
return out;
|
|
271
272
|
}
|
|
272
273
|
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); }
|
|
@@ -295,6 +296,8 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
295
296
|
if (n.kind === 'if' && n.cond) conds.push(n.cond);
|
|
296
297
|
for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
|
|
297
298
|
if (e.kind === 'call' && typeof e.callee === 'string') ordered.push({ call: e, line: e.line || n.line, node: n });
|
|
299
|
+
// `when (owner /= principal) notFound`, `unless (owner == principal) (permissionDenied ..)`: the condition is the first argument of the call
|
|
300
|
+
if (e.kind === 'call' && /(?:^|\.)(?:when|unless)$/.test(e.callee || '') && e.args && e.args[0]) conds.push(e.args[0]);
|
|
298
301
|
if (e.kind === 'union' && e.hs && e.hs.branchConds) for (const bc of e.hs.branchConds) conds.push(bc.cond);
|
|
299
302
|
}
|
|
300
303
|
}
|
|
@@ -349,6 +352,9 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
349
352
|
const hard = allCalls.findIndex((r) => r !== rd.call && isRejection(r) && !SCOTTY_STATUS.test(r.callee) && stopsHandler(r, allCalls));
|
|
350
353
|
if (hard >= 0) { inlineIdx = hard; inlineKind = rd.kind; inlineLine = allCalls[hard].line; inlineCallee = rd.callee; inlineRejectCallee = allCalls[hard].callee; break; }
|
|
351
354
|
}
|
|
355
|
+
// A credential read and a rejection that ends the handler exist, but the rejection is not before the first sensitive operation BY LINE (a `case`
|
|
356
|
+
// lists the success branch first and the rejecting branch last). Their order is then a question about control flow, answered from the syntax tree.
|
|
357
|
+
const inlineCandidate = reads.length > 0 && allCalls.some((r) => isRejection(r) && stopsHandler(r, allCalls));
|
|
352
358
|
const principals = new Set();
|
|
353
359
|
for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
|
|
354
360
|
// ids read from the request
|
|
@@ -360,7 +366,7 @@ function handlerFacts(ctx, fn, opts = {}) {
|
|
|
360
366
|
if (nm && /id$/i.test(nm)) idVars.add(a.target);
|
|
361
367
|
}
|
|
362
368
|
}
|
|
363
|
-
return { ordered, guardIdx, inlineIdx, inlineKind, inlineLine, inlineCallee, inlineRejectCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
|
|
369
|
+
return { ordered, guardIdx, inlineIdx, inlineCandidate, inlineKind, inlineLine, inlineCallee, inlineRejectCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
|
|
364
370
|
}
|
|
365
371
|
|
|
366
372
|
function hasOwnership(ctx, facts) {
|
|
@@ -368,7 +374,7 @@ function hasOwnership(ctx, facts) {
|
|
|
368
374
|
const mentionsPrincipal = (e) => { for (const n of identsIr(e)) if (principals.has(n)) return true; return false; };
|
|
369
375
|
// 1. a comparison involving the principal
|
|
370
376
|
for (const c of facts.conds) for (const x of walkIr(c)) {
|
|
371
|
-
if (x.kind === 'binary' && ['==', '===', '!='].includes(x.op) && (mentionsPrincipal(x.left) || mentionsPrincipal(x.right))) return { kind: 'principal-comparison' };
|
|
377
|
+
if (x.kind === 'binary' && ['==', '===', '!=', '/='].includes(x.op) && (mentionsPrincipal(x.left) || mentionsPrincipal(x.right))) return { kind: 'principal-comparison' };
|
|
372
378
|
if (x.kind === 'call' && /(?:^|\.)(?:elem|notElem)$/.test(x.callee || '') && (x.args || []).some(mentionsPrincipal)) return { kind: 'principal-membership' };
|
|
373
379
|
}
|
|
374
380
|
for (const o of facts.ordered) {
|
|
@@ -396,7 +402,7 @@ function hasOwnership(ctx, facts) {
|
|
|
396
402
|
// source, a sensitive call the walk did not find) makes the order UNKNOWN, and unknown is reported as late, never as authenticated.
|
|
397
403
|
// Leaf expressions are classified by lowering them alone and reusing the classifiers above (credentialReadsOf, isRejection, stopsHandler,
|
|
398
404
|
// 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)$/;
|
|
405
|
+
const HARD_REJECT_HEAD = /^(?:throwError|permissionDenied|notAuthenticated|raiseStatus)$/;
|
|
400
406
|
const SOFT_REJECT_HEAD = /^(?:respond|return|pure)$/;
|
|
401
407
|
|
|
402
408
|
function rhsBranches(rhs) {
|
|
@@ -425,7 +431,7 @@ function inlineOrder(ctx, fn, facts, opts) {
|
|
|
425
431
|
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
432
|
const classify = (e) => {
|
|
427
433
|
if (memo.has(e)) return memo.get(e);
|
|
428
|
-
let out = { reads: false, rej: false, sensN: 0 };
|
|
434
|
+
let out = { reads: false, rej: false, sensN: 0, stat: false, halt: false };
|
|
429
435
|
try {
|
|
430
436
|
const f2 = lowerHandlerExpr(ctx.project, src.mod, e, `order${seq++}`, Number.isFinite(e.line) ? e.line : 1);
|
|
431
437
|
const calls = [...callsOf(f2), ...nullaryRejectionsOf(f2)].filter((c) => typeof c.callee === 'string');
|
|
@@ -434,6 +440,10 @@ function inlineOrder(ctx, fn, facts, opts) {
|
|
|
434
440
|
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
441
|
rej: calls.some((c) => isRejection(c) && !SCOTTY_STATUS.test(c.callee) && stopsHandler(c, calls)),
|
|
436
442
|
sensN: callsOf(f2).filter((c) => typeof c.callee === 'string' && isSensitiveCall(ctx, c)).length,
|
|
443
|
+
// Scotty: `status 40x` only sets the code; a halting call (`finish`, `raise`, `redirect`) ends the handler. Both are tracked so the PAIR counts
|
|
444
|
+
// as a rejection wherever the two sit in sequence (`status status401 >> finish`), in either order of statements within one branch.
|
|
445
|
+
stat: calls.some((c) => SCOTTY_STATUS.test(c.callee) && isRejection(c)),
|
|
446
|
+
halt: haltLinesOf(f2).length > 0,
|
|
437
447
|
};
|
|
438
448
|
} catch { T.failed = true; }
|
|
439
449
|
// 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
|
|
@@ -441,7 +451,7 @@ function inlineOrder(ctx, fn, facts, opts) {
|
|
|
441
451
|
memo.set(e, out);
|
|
442
452
|
for (const x of walkAst(e)) {
|
|
443
453
|
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 }; }
|
|
454
|
+
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, stat: out.stat || sub.stat, halt: out.halt || sub.halt }; }
|
|
445
455
|
}
|
|
446
456
|
T.astSens += out.sensN;
|
|
447
457
|
memo.set(e, out);
|
|
@@ -459,6 +469,9 @@ function inlineOrder(ctx, fn, facts, opts) {
|
|
|
459
469
|
for (let i = 0; i < n; i++) acc.sens.push({ guarded: s.guarded, readMay: s.readMay });
|
|
460
470
|
const sp = spine(e);
|
|
461
471
|
const hn = sp && sp.head ? sp.head.name : null;
|
|
472
|
+
if (c.stat) s = { ...s, statusRej: true };
|
|
473
|
+
// a Scotty halting call after a rejecting status on this path, once a credential has been read
|
|
474
|
+
if (c.halt && n === 0 && s.statusRej && s.read) { acc.guards++; return { st: s, term: true }; }
|
|
462
475
|
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
476
|
if (c.rej && n > 0) T.unordered = true;
|
|
464
477
|
return { st: s, term: false };
|
|
@@ -586,6 +599,9 @@ function analyzeRoute(ctx, route, handlerFn, opts) {
|
|
|
586
599
|
const ord = R.framework === 'scotty' ? { verdict: 'before' } : inlineOrder(ctx, handlerFn, facts, opts);
|
|
587
600
|
if (ord.verdict === 'before') R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
|
|
588
601
|
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 }] };
|
|
602
|
+
} else if (facts.inlineCandidate && inlineOrder(ctx, handlerFn, facts, opts).verdict === 'before') {
|
|
603
|
+
// by line the rejection looked late or absent; the handler's structure proves every sensitive operation sits on the non-rejecting side of it
|
|
604
|
+
R.auth = { status: 'authenticated', kind: facts.inlineKind || 'token', evidence: [{ kind: 'inline-guard', callee: 'structure', line: R.line }] };
|
|
589
605
|
} else R.auth = { status: 'none', kind: null, evidence: [] };
|
|
590
606
|
// role
|
|
591
607
|
const roleCall = facts.ordered.find((o) => ROLE_CALL.test(o.call.callee || ''));
|
|
@@ -765,7 +781,10 @@ function servantHandlers(ctx, mod, apiName) {
|
|
|
765
781
|
const flat = [];
|
|
766
782
|
const go = (e) => { e = unparen(e); if (e && e.t === 'op' && e.op === ':<|>') { go(e.l); go(e.r); } else flat.push(e); };
|
|
767
783
|
if (body) go(body);
|
|
768
|
-
|
|
784
|
+
// `server _account rowId = do ...`: a single endpoint written as a function, whose parameters ARE the endpoint's auth, capture and query values
|
|
785
|
+
const clause = fun.clauses[0];
|
|
786
|
+
const params = flat.length === 1 && clause && (clause.pats || []).length ? clause.pats.map((x) => (x && x.t === 'pvar' ? x.name : null)) : null;
|
|
787
|
+
if (flat.length > 0) return { fnName: fun.name, handlers: flat, fun, params };
|
|
769
788
|
}
|
|
770
789
|
return null;
|
|
771
790
|
}
|
|
@@ -874,7 +893,7 @@ export function analyzeHaskellWeb(files, opts = {}) {
|
|
|
874
893
|
list.forEach((e, i) => {
|
|
875
894
|
const hAst = h && h.handlers.length === list.length ? h.handlers[i] : null;
|
|
876
895
|
if (h && h.handlers.length !== list.length) ctx.gaps.push({ kind: 'servant-handler-arity', detail: `${apiName} has ${list.length} endpoints but ${h.fnName} lists ${h.handlers.length} handlers; handlers are not paired`, file: mod.file, line: e.line });
|
|
877
|
-
add({ framework: 'servant', method: e.method, path: e.path, file: mod.file, line: e.line, mod, handlerAst: hAst, servant: e, owner: apiName, whereDecls: h && h.fun && h.fun.clauses[0] && h.fun.clauses[0].rhs.where });
|
|
896
|
+
add({ framework: 'servant', method: e.method, path: e.path, file: mod.file, line: e.line, mod, handlerAst: hAst, handlerParams: h && h.handlers.length === list.length ? h.params : null, servant: e, owner: apiName, whereDecls: h && h.fun && h.fun.clauses[0] && h.fun.clauses[0].rhs.where });
|
|
878
897
|
});
|
|
879
898
|
}
|
|
880
899
|
|
|
@@ -890,6 +909,23 @@ export function analyzeHaskellWeb(files, opts = {}) {
|
|
|
890
909
|
}
|
|
891
910
|
}
|
|
892
911
|
|
|
912
|
+
// Yesod handlers named by the framework's convention (`getInvoiceR`, `postPurgeR`, `handleHomeR`) whose route table is not in an analysed
|
|
913
|
+
// file (it usually lives in `config/routes`, read by a Template Haskell splice): the name and the `Handler` result type identify the entry point.
|
|
914
|
+
// A handler a route table already names is not added twice.
|
|
915
|
+
if (fw.has('yesod')) {
|
|
916
|
+
const named = new Set(pending.filter((p) => p.framework === 'yesod').map((p) => p.handlerName));
|
|
917
|
+
for (const mod of ctx.modules) {
|
|
918
|
+
if (!mod.imports.some((i) => /^Yesod(?:\.|$)/.test(i.module))) continue;
|
|
919
|
+
for (const fun of mod.groups.funs) {
|
|
920
|
+
const m = /^(get|post|put|delete|patch|options|head|handle)([A-Z][A-Za-z0-9_']*R)$/.exec(fun.name);
|
|
921
|
+
const sig = mod.sigs.get(fun.name);
|
|
922
|
+
if (!m || named.has(fun.name) || !sig || !/\bHandler(?:For|T)?\b/.test(sig.text)) continue;
|
|
923
|
+
const method = m[1] === 'handle' ? 'ANY' : m[1].toUpperCase();
|
|
924
|
+
add({ framework: 'yesod', method, path: `/${m[2].replace(/R$/, '').replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}`, file: mod.file, line: fun.line, mod, handlerName: fun.name, resource: m[2], owner: m[2], conventional: true });
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
893
929
|
// ── resolve handlers and analyse ─────────────────────────────────────────
|
|
894
930
|
for (const p of pending) {
|
|
895
931
|
const R = newRoute({ framework: p.framework, method: p.method, path: p.path, file: p.file, line: p.line, owner: p.owner });
|
|
@@ -919,7 +955,7 @@ export function analyzeHaskellWeb(files, opts = {}) {
|
|
|
919
955
|
else if (q) handlerName = q.name;
|
|
920
956
|
}
|
|
921
957
|
} else {
|
|
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; }
|
|
958
|
+
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>'; if (p.handlerParams) paramNames = p.handlerParams; } catch { handlerFn = null; }
|
|
923
959
|
}
|
|
924
960
|
}
|
|
925
961
|
R.handler = handlerFn ? { kind: handlerName === '<inline>' ? 'inline' : 'named', name: handlerName } : { kind: 'unknown', name: handlerName };
|
|
@@ -221,7 +221,8 @@ export function evaluateSupport(language, input) {
|
|
|
221
221
|
const strict = r.evidence.metric === 'sast-holdout' || r.evidence.metric === 'taint-holdout' ? (detection.layers || {})[key] && (detection.layers[key].strict || null) : null;
|
|
222
222
|
r.evidence = withInterval({ ...r.evidence, ...(strict && Number.isFinite(strict.precision) ? { strictPrecision: strict.precision } : {}) });
|
|
223
223
|
}
|
|
224
|
-
// Generalisation: the SAME layers measured on shapes absent from train, validation and holdout.
|
|
224
|
+
// Generalisation: the SAME layers measured on shapes absent from train, validation and holdout. When that split has become a DEVELOPMENT set (its
|
|
225
|
+
// failures were read and the engine was changed for them) the result is a regression number and is labelled so (`role`). It never changes a row's status (the
|
|
225
226
|
// PRD's gates are defined on the frozen holdout); it is stated beside it, with the shortfalls named, so "supported" is not read as more.
|
|
226
227
|
const un = input.unseen && input.unseen.ecosystems && input.unseen.ecosystems[language] && input.unseen.ecosystems[language].detection;
|
|
227
228
|
out.generalizationGaps = [];
|
|
@@ -234,7 +235,7 @@ export function evaluateSupport(language, input) {
|
|
|
234
235
|
if (!(u.precision >= TARGETS.precision)) shortfalls.push(`precision ${pct(u.precision)} < ${pct(TARGETS.precision)}`);
|
|
235
236
|
if (!(u.recall >= TARGETS.recall)) shortfalls.push(`recall ${pct(u.recall)} < ${pct(TARGETS.recall)}`);
|
|
236
237
|
if (!(u.f1 >= TARGETS.f1)) shortfalls.push(`F1 ${pct(u.f1)} < ${pct(TARGETS.f1)}`);
|
|
237
|
-
r.generalization = { status: shortfalls.length ? 'below-target' : 'meets-targets', shortfalls, cases: u.cases, tp: u.tp, fp: u.fp, fn: u.fn, tn: u.tn, precision: u.precision, recall: u.recall, f1: u.f1, strictPrecision: u.strict && u.strict.precision, ci: { precision: ciShapes(u.tp, u.tp + u.fp), recall: ciShapes(u.tp, u.tp + u.fn), level: 0.95, method: `Clopper-Pearson over shapes (${SHAPE_CLUSTER} near-identical cases per shape)` } };
|
|
238
|
+
r.generalization = { status: shortfalls.length ? 'below-target' : 'meets-targets', role: (input.unseen && input.unseen.splitRole) || 'generalisation', shortfalls, cases: u.cases, tp: u.tp, fp: u.fp, fn: u.fn, tn: u.tn, precision: u.precision, recall: u.recall, f1: u.f1, strictPrecision: u.strict && u.strict.precision, ci: { precision: ciShapes(u.tp, u.tp + u.fp), recall: ciShapes(u.tp, u.tp + u.fn), level: 0.95, method: `Clopper-Pearson over shapes (${SHAPE_CLUSTER} near-identical cases per shape)` } };
|
|
238
239
|
if (shortfalls.length) out.generalizationGaps.push(`${r.capability}: ${shortfalls.join(', ')}`);
|
|
239
240
|
}
|
|
240
241
|
// a capability whose tool is absent is blocked, not passed and not skipped
|
package/src/mcp/tools.js
CHANGED
|
@@ -475,6 +475,22 @@ export const scan_diff = {
|
|
|
475
475
|
},
|
|
476
476
|
};
|
|
477
477
|
|
|
478
|
+
// The ordered source-to-sink steps of a finding: its `trace` when the detector wrote one, else the `chain` the IR taint
|
|
479
|
+
// engine records (file, line, label, kind, provenance). Null when the finding carries neither.
|
|
480
|
+
function _flowSteps(f) {
|
|
481
|
+
if (Array.isArray(f.trace) && f.trace.length) return f.trace;
|
|
482
|
+
if (f.trace && !Array.isArray(f.trace)) return f.trace;
|
|
483
|
+
if (Array.isArray(f.chain) && f.chain.length) {
|
|
484
|
+
return f.chain.map((s) => ({ file: s.file ?? null, line: s.line ?? null, label: s.label ?? null, kind: s.kind || (s.provenance ? 'source' : 'step'), provenance: s.provenance ?? null }));
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
// Labels of the structured source, sink and rule, for matching only.
|
|
489
|
+
function _flowEnds(f) {
|
|
490
|
+
const lab = (x) => (x && typeof x === 'object' ? [x.label, x.family, x.kind].filter((v) => typeof v === 'string').join(' ') : '');
|
|
491
|
+
return [lab(f.source), lab(f.sink), typeof f.rule === 'string' ? f.rule : ''].join(' ');
|
|
492
|
+
}
|
|
493
|
+
|
|
478
494
|
// ─── query_taint ─────────────────────────────────────────────────────────────
|
|
479
495
|
export const query_taint = {
|
|
480
496
|
name: 'query_taint',
|
|
@@ -505,13 +521,16 @@ export const query_taint = {
|
|
|
505
521
|
// dilutes its reasoning; the agent receives a bounded slice plus the
|
|
506
522
|
// cursor to fetch the rest if it wants.
|
|
507
523
|
const all = (scan.findings || []).filter(f => {
|
|
508
|
-
|
|
524
|
+
// The structured flow counts too: an IR taint finding (Haskell included) records its path as `source`, `sink`, `rule`
|
|
525
|
+
// and `chain`, and carries no free-text description or `trace`, so matching only the text fields would answer
|
|
526
|
+
// "no path" for a path the scan did find.
|
|
527
|
+
const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(_flowSteps(f) || ''), _flowEnds(f)].join(' ').toLowerCase();
|
|
509
528
|
return hay.includes(srcL) && hay.includes(sinkL);
|
|
510
529
|
});
|
|
511
530
|
const page = all.slice(off, off + lim).map(f => redactFinding({
|
|
512
531
|
id: f.id, severity: f.severity, file: f.file, line: f.line,
|
|
513
532
|
title: f.title || f.vuln, description: f.description,
|
|
514
|
-
trace: f
|
|
533
|
+
trace: _flowSteps(f),
|
|
515
534
|
}));
|
|
516
535
|
return {
|
|
517
536
|
_meta: META,
|
|
@@ -551,7 +570,7 @@ export const explain_finding = {
|
|
|
551
570
|
title: f.title || f.vuln, cwe: f.cwe,
|
|
552
571
|
description: f.description, remediation: f.remediation,
|
|
553
572
|
snippet: f.snippet || null,
|
|
554
|
-
trace: f
|
|
573
|
+
trace: _flowSteps(f),
|
|
555
574
|
});
|
|
556
575
|
// Harness-anatomy #1: explain_finding's trace is the most-likely-large
|
|
557
576
|
// field on a single finding. Offload when it crosses the threshold so
|
|
@@ -1211,6 +1230,9 @@ export const synthesize_fix = {
|
|
|
1211
1230
|
languageFix.applyWith = { finding_id: f.id, confirm: true, plan_digest: languageFix.planDigest };
|
|
1212
1231
|
}
|
|
1213
1232
|
else if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
|
|
1233
|
+
} else if (lc.isLanguageDependencyFinding(f)) {
|
|
1234
|
+
// Named, not silent: a Hackage or flake dependency is fixed by raising a constraint, which has its own tool.
|
|
1235
|
+
languageFix = { status: 'use-sca-upgrade', ok: false, label: null, tier: null, reason: 'this is a dependency finding: use synthesize_sca_upgrade for the constraint edit (apply_sca_upgrade writes it)', diff: null, explanation: null, consequences: [] };
|
|
1214
1236
|
}
|
|
1215
1237
|
} catch { /* best-effort: the preview is advisory */ }
|
|
1216
1238
|
}
|
|
@@ -1625,6 +1647,22 @@ export const query_cache_telemetry = {
|
|
|
1625
1647
|
},
|
|
1626
1648
|
};
|
|
1627
1649
|
|
|
1650
|
+
async function _languageUpgrade(lc, f, ctx, apply) {
|
|
1651
|
+
const proj = lc.loadLanguageProject(ctx.sessionRoot);
|
|
1652
|
+
return lc.languageUpgradePreview(f, { ...proj.files, ...proj.manifests }, { apply, root: ctx.sessionRoot });
|
|
1653
|
+
}
|
|
1654
|
+
// The result shape both SCA upgrade tools return for a Hackage or flake dependency. `verified` is never true: the edit
|
|
1655
|
+
// changes a declared constraint or a ref, not the resolved version.
|
|
1656
|
+
function _languageUpgradeResult(f, r) {
|
|
1657
|
+
return {
|
|
1658
|
+
ok: true, mode: r.ok ? 'source-edit' : 'blocked', ecosystem: f.ecosystem, package: f.package || null,
|
|
1659
|
+
currentVersion: f.version || null, targetVersion: null, command: null,
|
|
1660
|
+
reason: r.ok ? null : (r.reason || 'no upgrade could be planned'),
|
|
1661
|
+
languageUpgrade: { status: r.status || null, tier: r.tier || null, label: r.label || null, file: r.file || null, diff: r.diff || null, explanation: r.explanation || null, consequences: r.consequences || [], backup: r.backup ? r.backup.id || null : null },
|
|
1662
|
+
verified: false,
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1628
1666
|
// ─── synthesize_sca_upgrade ───────────────────────────────────────────────
|
|
1629
1667
|
// Phase 3 / Item 5 of the SCA improvement plan. Read-only counterpart to
|
|
1630
1668
|
// apply_sca_upgrade — produces a structured upgrade plan via the
|
|
@@ -1653,6 +1691,10 @@ export const synthesize_sca_upgrade = {
|
|
|
1653
1691
|
if (f.type !== 'vulnerable_dep') {
|
|
1654
1692
|
return { _meta: META, ok: false, reason: 'finding is not an SCA vulnerable_dep — use synthesize_fix for SAST findings' };
|
|
1655
1693
|
}
|
|
1694
|
+
// Hackage constraints and flake inputs are source edits the language planner owns; without this the generic planner
|
|
1695
|
+
// answers "no automated upgrade" for a dependency the CLI can fix. Read-only: nothing is written or run.
|
|
1696
|
+
const lc = await import('../language/context.js');
|
|
1697
|
+
if (lc.isLanguageDependencyFinding(f)) return { _meta: META, ..._languageUpgradeResult(f, await _languageUpgrade(lc, f, ctx, false)) };
|
|
1656
1698
|
const { planScaUpgrade } = await _getScaUpgrade();
|
|
1657
1699
|
const plan = await planScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f });
|
|
1658
1700
|
return { _meta: META, ...plan };
|
|
@@ -1693,6 +1735,15 @@ export const apply_sca_upgrade = {
|
|
|
1693
1735
|
if (f.type !== 'vulnerable_dep') {
|
|
1694
1736
|
return { _meta: META, applied: false, reason: 'finding is not an SCA vulnerable_dep — use apply_fix for SAST findings' };
|
|
1695
1737
|
}
|
|
1738
|
+
const lc = await import('../language/context.js');
|
|
1739
|
+
if (lc.isLanguageDependencyFinding(f)) {
|
|
1740
|
+
// Plan first, confine the one file it names, then write it with a backup and a history entry (`undo` reverts it).
|
|
1741
|
+
const plan = await _languageUpgrade(lc, f, ctx, false);
|
|
1742
|
+
if (!plan.ok) return { _meta: META, applied: false, ..._languageUpgradeResult(f, plan) };
|
|
1743
|
+
try { _confine(ctx.sessionRoot, plan.file, 'upgrade.file'); } catch (e) { return { _meta: META, applied: false, reason: String((e && e.message) || e) }; }
|
|
1744
|
+
const done = await _languageUpgrade(lc, f, ctx, true);
|
|
1745
|
+
return { _meta: META, applied: done.applied === true, ..._languageUpgradeResult(f, done), testsRun: false, testsNote: 'a constraint edit is not re-resolved or re-tested by this tool; resolve (for example `cabal build --dry-run`), rescan and run the project tests before relying on it' };
|
|
1746
|
+
}
|
|
1696
1747
|
const { applyScaUpgrade } = await _getScaUpgrade();
|
|
1697
1748
|
const result = await applyScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f, runTests: run_tests });
|
|
1698
1749
|
return { _meta: META, ...result };
|
|
@@ -167,6 +167,16 @@ const FAMILY_ALIAS = {
|
|
|
167
167
|
'license': 'license-graph',
|
|
168
168
|
'cloud-iam-overpermissive': 'iam-overpermissive',
|
|
169
169
|
'http-no-tls': 'crypto-tls-version',
|
|
170
|
+
// Haskell producers (language/haskell-*.js) name the same defects more finely; each maps to the canonical family of
|
|
171
|
+
// the same kind of defect, and a family with no canonical counterpart stays unmapped and is listed as such.
|
|
172
|
+
'missing-authentication': 'auth-missing',
|
|
173
|
+
'weak-randomness': 'crypto-weak-rng',
|
|
174
|
+
'weak-hash': 'crypto-weak-hash',
|
|
175
|
+
'password-hashing': 'crypto-kdf-weak',
|
|
176
|
+
'tls-validation': 'crypto-tls-no-verify',
|
|
177
|
+
'hardcoded-credentials': 'hardcoded-secret',
|
|
178
|
+
'broken-object-authorization': 'idor',
|
|
179
|
+
'broken-function-authorization': 'authz',
|
|
170
180
|
};
|
|
171
181
|
|
|
172
182
|
function _canonical(family) {
|
|
@@ -140,9 +140,13 @@ export function buildEntrypointInventory(fileContents, opts = {}) {
|
|
|
140
140
|
const line = Number.isInteger(route.line) ? route.line : 0;
|
|
141
141
|
const method = typeof route.method === 'string' ? route.method : 'GET';
|
|
142
142
|
const path = typeof route.path === 'string' ? route.path : '';
|
|
143
|
-
|
|
143
|
+
// A handler is a name for most frameworks and an object ({kind, name}) for Haskell routes; String() of the object
|
|
144
|
+
// is "[object Object]". An inline handler has no name of its own, so the route stands in for it.
|
|
145
|
+
const h = route.handler;
|
|
146
|
+
const handlerName = typeof h === 'string' ? h : (h && typeof h === 'object' && typeof h.name === 'string' && h.name && !/^<.*>$/.test(h.name) ? h.name : '');
|
|
147
|
+
const isWebhook = /webhook/i.test(path) || /webhook/i.test(handlerName);
|
|
144
148
|
const type = isWebhook ? 'webhook' : 'http';
|
|
145
|
-
const name = _clip(
|
|
149
|
+
const name = _clip(handlerName || `${method} ${path}`.trim() || type);
|
|
146
150
|
|
|
147
151
|
// Trust: an authenticated route (hasAuth) or a nearby auth token wins.
|
|
148
152
|
let trust = route.hasAuth === true ? 'authenticated' : 'unauthenticated';
|