@clear-capabilities/agentic-security-scanner 0.160.0 → 0.161.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.
@@ -7,7 +7,8 @@ import { join } from 'node:path';
7
7
  import { analyzeHaskellManifests } from './haskell-manifests.js';
8
8
  import { buildHaskellIR } from './haskell-ir.js';
9
9
  import { getLastRefresh, refreshHackageAdvisories, liveFeedEnabled, FEED_ENV } from './haskell-advisory-feed.js';
10
- import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES } from './haskell-sca.js';
10
+ import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES, rangeToIntervals, intervalsContain, baseRangeForGhc } from './haskell-sca.js';
11
+ import { deriveCppContext } from './haskell-cpp.js';
11
12
 
12
13
  const MANIFEST = /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.[a-z]+)?|package\.yaml|stack\.yaml|stack\.yaml\.lock)$/;
13
14
  const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
@@ -38,12 +39,16 @@ export function hackageComponents(files) {
38
39
  // workspace package, not to Hackage; reporting it would raise advisories about the project's own (usually fixed) version as if it were a
39
40
  // dependency. Found by the live Hackage feed bench (aeson's own test suite depends on aeson).
40
41
  const defined = new Set([...(manifests.packages || []), ...(manifests.hpack || [])].map((p) => p.name).filter(Boolean));
42
+ // Every distinct DECLARED USE is kept (not just the first per package and scope): a second manifest or component with a different range was
43
+ // never evaluated, so a worse status could hide behind a milder kept one. The evaluator reports the worst per package, scope and advisory
44
+ // and lists the components that carry it; `components` below keeps the old one-per-package-and-scope shape for inventories.
41
45
  const seen = new Map();
42
46
  for (const d of manifests.dependencies || []) {
43
47
  if (defined.has(d.name)) continue;
44
48
  const lock = lockedFor(d.name, d.manifest);
45
49
  const resolved = (lock && lock.version) || d.exactPin || null;
46
- const key = `${d.name}@${d.scope || 'runtime'}@${resolved || ''}`; // a covered (resolved) use is not merged into an uncovered one
50
+ // a covered (resolved) use is not merged into an uncovered one; an uncovered use is distinguished by where and how it is declared
51
+ const key = `${d.name}@${d.scope || 'runtime'}@${resolved || ''}@${resolved ? '' : `${d.manifest}|${d.component || ''}|${d.declaredRange || ''}|${d.rangeKind || ''}`}`;
47
52
  if (seen.has(key)) continue;
48
53
  seen.set(key, {
49
54
  ecosystem: 'hackage', name: d.name, version: resolved, declaredRange: d.declaredRange || null, unbounded: !resolved && !d.declaredRange && d.rangeKind === 'unbounded',
@@ -71,7 +76,96 @@ export function hackageComponents(files) {
71
76
  c.unbounded = false;
72
77
  c.rangeInheritedFromSibling = true;
73
78
  }
74
- return { components: [...seen.values()], manifests };
79
+ // Project-level bounds from cabal.project: `constraints:` narrow a declared range (the solver must satisfy both) and `allow-newer` /
80
+ // `allow-older` widen it. Both apply only to a use with no resolved version, from a project file that actually governs the manifest.
81
+ const bounds = projectBounds(manifests);
82
+ for (const c of seen.values()) {
83
+ if (c.version) continue;
84
+ const gov = bounds.governing(c.manifest);
85
+ if (!gov.length) continue;
86
+ const cons = bounds.constraintsFor(c.name, gov);
87
+ const relax = bounds.relaxationsFor(c.name, gov);
88
+ if (relax.length) c.relaxation = relax;
89
+ if (!cons.length) continue;
90
+ // an exact pin inside the declared range IS the resolved version; anything else is an interval to intersect
91
+ const pins = cons.filter((x) => x.pin);
92
+ const distinctPins = [...new Set(pins.map((x) => x.pin))];
93
+ const declaredIv = c.declaredRange ? rangeToIntervals(c.declaredRange) : [{ lo: null, loInc: false, hi: null, hiInc: false }];
94
+ if (distinctPins.length === 1 && declaredIv && (!c.declaredRange || intervalsContain(declaredIv, distinctPins[0])) && cons.every((x) => x.pin || (rangeToIntervals(x.text) && intervalsContain(rangeToIntervals(x.text), distinctPins[0])))) {
95
+ const p = pins[0];
96
+ c.version = distinctPins[0]; c.resolution = 'constraint-pin'; c.versionSource = { file: p.file, line: p.line || null }; c.unbounded = false;
97
+ c.projectConstraints = cons.map(({ text, file, line }) => ({ text, file, line }));
98
+ } else {
99
+ c.projectConstraints = cons.map(({ text, file, line }) => ({ text, file, line }));
100
+ }
101
+ }
102
+ // Collapse uses that ended up identical (same package, scope, version, bounds): one entry, every declaring component kept in `carriers`.
103
+ const merged = new Map();
104
+ for (const c of seen.values()) {
105
+ const key = `${c.name}@${c.scope}@${c.version || ''}@${c.version ? '' : `${c.declaredRange || ''}|${c.unbounded}|${(c.projectConstraints || []).map((x) => x.text).join(',')}|${(c.relaxation || []).map((x) => x.direction).join(',')}`}`;
106
+ const carrier = { file: c.manifest, line: c.line, target: c.target, componentKind: c.componentKind, declaredRange: c.declaredRange };
107
+ if (merged.has(key)) { merged.get(key).carriers.push(carrier); continue; }
108
+ merged.set(key, { ...c, carriers: [carrier] });
109
+ }
110
+ const uses = [...merged.values()];
111
+ const first = new Map();
112
+ for (const c of uses) { const k = `${c.name}@${c.scope}@${c.version || ''}`; if (!first.has(k)) first.set(k, c); }
113
+ return { components: [...first.values()], uses, manifests, bounds: { conditionalConstraintsSkipped: bounds.conditionalSkipped } };
114
+ }
115
+
116
+ const isRemotePattern = (p) => /^[a-z][a-z0-9+.-]*:\/\//i.test(p);
117
+ /** Does a cabal.project `packages:` pattern name this manifest (a .cabal, or a package.yaml whose cabal file is generated beside it)? */
118
+ function patternNames(pattern, relManifest) {
119
+ let pat = String(pattern).trim().replace(/^\.\//, '');
120
+ if (pat === '' || pat === '.') pat = '*.cabal';
121
+ else if (pat.endsWith('/')) pat = `${pat}*.cabal`;
122
+ else if (!pat.endsWith('.cabal')) pat = `${pat}/*.cabal`;
123
+ const rel = /(?:^|\/)package\.yaml$/.test(relManifest) ? relManifest.replace(/package\.yaml$/, 'package.cabal') : relManifest;
124
+ const re = new RegExp(`^${pat.split('**').map((seg) => seg.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*')}$`);
125
+ return re.test(rel);
126
+ }
127
+
128
+ /**
129
+ * cabal.project constraints and relaxations, scoped to the manifests a project file governs. A project governs a manifest only when the
130
+ * manifest sits at or below the project's directory AND matches one of its `packages:` patterns (a project with no `packages:` governs
131
+ * the .cabal file in its own directory, which is cabal's default). A constraint under an `if` is not in force everywhere and is not applied
132
+ * (counted in `conditionalSkipped` and disclosed). A freeze file is handled as a resolution, not here.
133
+ */
134
+ function projectBounds(manifests) {
135
+ const dirOf = (p) => (typeof p === 'string' && p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
136
+ const isFreeze = (f) => /\.freeze$|(?:^|\/)cabal\.config$/.test(f || '');
137
+ const projects = (manifests.projects || []).filter((p) => p.kind === 'cabal-project' && !isFreeze(p.file));
138
+ const constraints = [];
139
+ let conditionalSkipped = 0;
140
+ for (const c of manifests.constraints || []) {
141
+ if (c.kind !== 'range' || !c.range || isFreeze(c.manifest)) continue;
142
+ if (c.qualifier && c.qualifier !== 'any') continue;
143
+ if (c.conditions && c.conditions.length) { conditionalSkipped += 1; continue; }
144
+ constraints.push({ name: c.name, text: c.rangeText, file: c.file, line: c.line, project: c.manifest, pin: c.range.t === 'cmp' && c.range.op === '==' && !c.range.wildcard ? c.range.text : null });
145
+ }
146
+ const governs = (project, manifest) => {
147
+ const base = dirOf(project.file);
148
+ let rel;
149
+ if (base === '') rel = manifest;
150
+ else if (manifest.startsWith(`${base}/`)) rel = manifest.slice(base.length + 1);
151
+ else return false;
152
+ const pats = (project.packages || []).map((x) => x.pattern).filter((x) => typeof x === 'string' && !isRemotePattern(x));
153
+ return (pats.length ? pats : ['.']).some((pat) => patternNames(pat, rel));
154
+ };
155
+ const relaxMatches = (target, dep) => {
156
+ const t = String(target || '').trim().replace(/^\^/, '');
157
+ if (t === '' || t === 'all' || t === '*' || t === '*:*') return true;
158
+ const parts = t.split(':');
159
+ const last = parts[parts.length - 1];
160
+ return last === dep || last === '*';
161
+ };
162
+ return {
163
+ conditionalSkipped,
164
+ governing: (manifest) => projects.filter((p) => typeof manifest === 'string' && governs(p, manifest)),
165
+ constraintsFor: (name, gov) => constraints.filter((c) => c.name === name && gov.some((p) => p.file === c.project)),
166
+ // A relaxation naming package `a:dep` is applied to `dep` wherever it is used: widening more than the cabal semantics is the safe direction.
167
+ relaxationsFor: (name, gov) => gov.flatMap((p) => (p.allowRelaxations || []).filter((r) => relaxMatches(r.target, name)).map((r) => ({ direction: r.direction, target: r.target, file: r.file, line: r.line }))),
168
+ };
75
169
  }
76
170
 
77
171
  /**
@@ -130,6 +224,70 @@ export async function prefetchHackageFeed(files, { env = process.env, resolvedCo
130
224
  } catch (e) { return { status: 'failed', detail: `the feed step failed: ${String((e && e.message) || e).slice(0, 120)}` }; }
131
225
  }
132
226
 
227
+ /**
228
+ * The compiler this project is built with, only when it is stated: `with-compiler`, a Stack `compiler:`, a plan's compiler-id, or a
229
+ * `tested-with` whose every item is the same exact version (a claim by the project, weaker than a pin). Sources that disagree, or a
230
+ * `tested-with` listing several versions, leave it UNKNOWN, never a guess.
231
+ * @returns {{version: string|null, label: string, basis: string|null, reason: string|null}}
232
+ */
233
+ export function projectCompiler(files, manifests, resolved) {
234
+ const pins = [];
235
+ const take = (v, basis) => { if (typeof v === 'string') { const m = /(?:^|[\\/])ghc-(\d+(?:\.\d+)+)$/.exec(v.trim()); if (m) pins.push({ version: m[1], basis }); } };
236
+ for (const p of (manifests && manifests.projects) || []) take(p.withCompiler, 'with-compiler');
237
+ for (const s of (manifests && manifests.stack) || []) take(s.compiler, 'stack compiler');
238
+ const pc = resolved && resolved.compiler;
239
+ if (pc && /^ghc$/i.test(pc.name || 'ghc') && pc.version) pins.push({ version: pc.version, basis: 'plan compiler-id' });
240
+ const distinct = [...new Set(pins.map((p) => p.version))];
241
+ if (distinct.length > 1) return { version: null, label: 'unknown compiler', basis: null, reason: `the project states conflicting compilers: ${pins.map((p) => `${p.basis} ${p.version}`).join(', ')}` };
242
+ if (distinct.length === 1) return { version: distinct[0], label: `GHC ${distinct[0]}`, basis: [...new Set(pins.map((p) => p.basis))].join(' + '), reason: null };
243
+ let tested = null;
244
+ try { tested = deriveCppContext(files).ghc; } catch { /* unreadable manifests state nothing */ }
245
+ if (tested && tested.basis === 'tested-with') {
246
+ const vs = [...new Set(tested.versions.map((v) => v.join('.')))];
247
+ if (vs.length === 1) return { version: vs[0], label: `GHC ${vs[0]}`, basis: 'tested-with (the project\'s own claim, not a pin)', reason: null };
248
+ return { version: null, label: 'unknown compiler', basis: null, reason: `tested-with lists several GHC versions: ${vs.join(', ')}; none is pinned` };
249
+ }
250
+ return { version: null, label: 'unknown compiler', basis: null, reason: 'no compiler is pinned by with-compiler, a Stack compiler or a plan, and no tested-with names exactly one version' };
251
+ }
252
+
253
+ /** A compiler-provided package takes its version from the plan's boot unit, else (for base) the compiler's base series; otherwise it stays as declared. */
254
+ function resolveBootComponent(c, compiler, resolved) {
255
+ if (!c.ghcComponent || c.version) return c;
256
+ const boot = resolved && resolved.bootLibraries && resolved.bootLibraries.find((b) => b.name === c.name && b.version);
257
+ if (boot) return { ...c, version: boot.version, resolution: 'plan-compiler', versionSource: boot.versionSource || null, compilerDerived: { compiler: compiler.label, basis: 'plan boot library' } };
258
+ if (compiler.version && c.name === 'base') {
259
+ const range = baseRangeForGhc(compiler.version);
260
+ if (range) return { ...c, declaredRange: range, unbounded: false, declaredRangeAsWritten: c.declaredRange || null, compilerDerived: { compiler: compiler.label, basis: compiler.basis, rule: `base ${range} is the base series of ${compiler.label}` } };
261
+ }
262
+ return c;
263
+ }
264
+
265
+ const GHC_RANK = { affected: 3, 'possibly-affected': 2, unknown: 1 };
266
+ /** One finding per advisory for the project's compiler: members, the worst status, and a remediation that names the compiler. */
267
+ function groupCompilerFindings(findings, compiler) {
268
+ const byAdvisory = new Map();
269
+ for (const f of findings) { const k = `${f.osvId}\u0000${(f.ids && f.ids[0]) || ''}`; if (!byAdvisory.has(k)) byAdvisory.set(k, []); byAdvisory.get(k).push(f); }
270
+ const out = [];
271
+ for (const members of byAdvisory.values()) {
272
+ const rank = (f) => GHC_RANK[f.matchStatus] || 0;
273
+ const lead = members.slice().sort((a, b) => rank(b) - rank(a))[0];
274
+ const packages = [...new Set(members.map((m) => m.name))].sort();
275
+ const fixed = [...new Set(members.flatMap((m) => m.fixedIn || []))];
276
+ const list = packages.join(', ');
277
+ const fixText = fixed.length ? `a release that ships ${list} ${fixed.join(' or ')} or later` : `a release with a fixed ${list} (none is published yet)`;
278
+ const remediation = compiler.version
279
+ ? `${list} ${packages.length > 1 ? 'are' : 'is'} provided by the compiler, and this project builds with ${compiler.label} (${compiler.basis}). Upgrade GHC to ${fixText}; a Cabal bound does not change the compiler's own library.`
280
+ : `${list} ${packages.length > 1 ? 'are' : 'is'} provided by the compiler, and this project's compiler is unknown (${compiler.reason}), so which ${list} version applies cannot be decided. Pin one with with-compiler in cabal.project, or supply a Cabal plan, to decide it. The fix is a compiler upgrade: upgrade GHC to ${fixText}.`;
281
+ out.push({
282
+ ...lead, name: list, packages, grouped: 'compiler', ghcComponent: true, compiler: { version: compiler.version, label: compiler.label, basis: compiler.basis, reason: compiler.reason },
283
+ matchStatus: lead.matchStatus, remediation,
284
+ members: members.map((m) => ({ name: m.name, scope: m.scope, version: m.version, declaredRange: m.declaredRange, matchStatus: m.matchStatus, matchReason: m.matchReason, resolution: m.resolution, carriedBy: m.carriedBy, ...(m.compilerDerived ? { compilerDerived: m.compilerDerived } : {}) })),
285
+ memberCount: members.length,
286
+ });
287
+ }
288
+ return out;
289
+ }
290
+
133
291
  /** Imports and import-qualified callees across the project's Haskell sources. */
134
292
  export function collectUsage(files) {
135
293
  const hs = {};
@@ -156,9 +314,15 @@ export function analyzeHaskellSupply(files, { db = undefined, root = null, env =
156
314
  if (!components.length) return out;
157
315
  let feedReason = null;
158
316
  if (db === undefined) { const c = configuredAdvisoryDb(root, env); db = c.db; feedReason = c.reason; }
159
- const ev = evaluateComponents(components, db, { kev, epss });
160
- out.statuses = ev.statuses;
317
+ const compiler = projectCompiler(files, manifests, resolved);
318
+ out.compiler = compiler;
319
+ // Evaluate every distinct declared use (the evaluator reports the worst per package, scope and advisory), with compiler-provided packages
320
+ // resolved against the project's compiler where that can be decided.
321
+ const evalComponents = mergeResolved(hc.uses, resolved).map((c) => resolveBootComponent(c, compiler, resolved));
322
+ const ev = evaluateComponents(evalComponents, db, { kev, epss });
323
+ out.statuses = ev.statuses.map((s) => (s.ghcComponent ? { ...s, compiler: compiler.label } : s));
161
324
  out.feed = ev.feed;
325
+ if (hc.bounds && hc.bounds.conditionalConstraintsSkipped) out.gaps.push({ kind: 'conditional-constraints-not-applied', detail: `${hc.bounds.conditionalConstraintsSkipped} cabal.project constraint(s) sit under a condition (if os(...), flag, compiler) and were NOT applied to declared ranges; they are not in force everywhere, so a declared range they would narrow is judged as written` });
162
326
  if (!db) out.gaps.push({ kind: 'advisory-feed-unavailable', detail: `${feedReason || ev.feed.detail}. ${components.length} Hackage dependencies were NOT checked against any advisory: the absence of findings is not a clean result.` });
163
327
  else if (ev.feed.status === 'stale-cache') out.gaps.push({ kind: 'advisory-feed-stale', detail: ev.feed.detail });
164
328
  const notCovered = ev.statuses.filter((x) => x.status === 'feed-incomplete' || x.status === 'feed-stale');
@@ -173,8 +337,13 @@ export function analyzeHaskellSupply(files, { db = undefined, root = null, env =
173
337
  f.reachability = r;
174
338
  f.functionReachable = r.function === 'reachable' ? 'reachable' : (r.function === 'not-imported' ? 'unreachable' : 'unknown');
175
339
  f.reachabilityTier = r.import === 'imported' ? (r.function === 'reachable' ? 'function-reachable' : 'import-reachable') : (r.import === 'not-imported' ? 'not-imported' : 'unknown');
176
- out.supplyChain.push({ ...f, file: f.file, line: f.line });
177
340
  }
341
+ // Compiler-provided packages (GHC boot libraries) are one finding per advisory for the project's compiler, not one per package and scope:
342
+ // the fix is a compiler upgrade whatever the Cabal bounds say. Every member stays listed in the group and every use keeps its status row.
343
+ const ordinary = ev.findings.filter((f) => !f.ghcComponent);
344
+ const groups = groupCompilerFindings(ev.findings.filter((f) => f.ghcComponent), compiler);
345
+ for (const f of [...ordinary, ...groups]) out.supplyChain.push({ ...f, file: f.file, line: f.line });
346
+ out.counts = { dependencyFindings: ordinary.length, compilerAdvisories: groups.length, compilerMemberRows: groups.reduce((n, g) => n + g.memberCount, 0), statusRows: out.statuses.length };
178
347
  // policy
179
348
  const si = sourceIntegrity(manifests || {});
180
349
  for (const s of si.filter((x) => x.finding)) {
@@ -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
- R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
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
  }
@@ -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 ──────────────────────────────────────────────────────────────────
@@ -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 });