@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.
@@ -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
- if (flat.length > 0) return { fnName: fun.name, handlers: flat, fun };
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. It never changes a row's status (the
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
- const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
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.trace || null,
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.trace || null,
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
- const isWebhook = /webhook/i.test(path) || /webhook/i.test(String(route.handler || ''));
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(route.handler || `${method} ${path}`.trim() || type);
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';