@clear-capabilities/agentic-security-scanner 0.159.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.
- package/CHANGELOG.md +28 -0
- package/dist/1919.index.js +1 -1
- package/dist/2432.index.js +10 -1
- package/dist/4113.index.js +1 -1
- package/dist/7033.index.js +4 -1
- package/dist/7838.index.js +1 -1
- package/dist/8714.index.js +1 -1
- package/dist/8798.index.js +1 -1
- package/dist/9560.index.js +170 -41
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +12 -9
- package/src/dataflow/engine.js +20 -4
- package/src/engine.js +29 -8
- package/src/language/CLAUDE.md +6 -1
- package/src/language/aibom.js +4 -1
- package/src/language/fix-lifecycle.js +9 -0
- package/src/language/haskell-ir.js +47 -7
- package/src/language/haskell-sca.js +87 -7
- package/src/language/haskell-supply.js +175 -6
- package/src/language/haskell-web.js +200 -18
- package/src/language/nix-fix.js +1 -1
- package/src/language/resolved-pass.js +4 -1
- package/src/language/witness.js +1 -1
- package/src/mcp/CLAUDE.md +34 -3
- package/src/mcp/tools.js +168 -39
- package/src/posture/blast-radius.js +15 -1
- package/src/posture/remediation-ledger.js +10 -1
- package/src/sast/pipeline.js +51 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
160
|
-
out.
|
|
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
|
-
|
|
584
|
+
// Scotty reports its halting points reliably. For WAI, Servant and Yesod the candidate (a credential read and a rejection that ends the
|
|
585
|
+
// handler) is only credited once the STRUCTURE of the handler proves the rejection precedes every sensitive operation.
|
|
586
|
+
const ord = R.framework === 'scotty' ? { verdict: 'before' } : inlineOrder(ctx, handlerFn, facts, opts);
|
|
587
|
+
if (ord.verdict === 'before') R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
|
|
588
|
+
else R.auth = { status: 'late', kind: null, evidence: [{ kind: 'late-guard', callee: facts.inlineRejectCallee || facts.inlineCallee, line: facts.inlineLine, order: ord.verdict, reason: ord.reason }] };
|
|
407
589
|
} else R.auth = { status: 'none', kind: null, evidence: [] };
|
|
408
590
|
// role
|
|
409
591
|
const roleCall = facts.ordered.find((o) => ROLE_CALL.test(o.call.callee || ''));
|
|
@@ -730,14 +912,14 @@ export function analyzeHaskellWeb(files, opts = {}) {
|
|
|
730
912
|
// a handler bound in the enclosing `where` (Servant servers usually are)
|
|
731
913
|
const local = (p.whereDecls || []).find((d) => d.name === head.name && !head.qual);
|
|
732
914
|
if (local && local.clause && local.clause.rhs && local.clause.rhs.body && !(local.clause.rhs.guards && local.clause.rhs.guards.length)) {
|
|
733
|
-
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, local.clause.rhs.body, `${p.framework}-${head.name}`, local.line || p.line); handlerName = head.name; paramNames = paramsOf(local.clause); } catch { handlerFn = null; }
|
|
915
|
+
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, local.clause.rhs.body, `${p.framework}-${head.name}`, local.line || p.line); ctx.handlerAst.set(handlerFn, { mod: p.mod, rhss: [local.clause.rhs] }); handlerName = head.name; paramNames = paramsOf(local.clause); } catch { handlerFn = null; }
|
|
734
916
|
} else {
|
|
735
917
|
const q = ctx.qualify(p.mod, head);
|
|
736
918
|
if (q && q.qid) { handlerFn = ctx.fnFor(q); handlerName = q.name; const f = topFun(p.mod, head.name); if (f && f.clauses[0]) paramNames = paramsOf(f.clauses[0]); }
|
|
737
919
|
else if (q) handlerName = q.name;
|
|
738
920
|
}
|
|
739
921
|
} else {
|
|
740
|
-
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, p.handlerAst, `${p.framework}-${p.line}`, p.line); handlerName = '<inline>'; } catch { handlerFn = null; }
|
|
922
|
+
try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, p.handlerAst, `${p.framework}-${p.line}`, p.line); ctx.handlerAst.set(handlerFn, { mod: p.mod, rhss: [{ guards: null, body: p.handlerAst, where: [] }] }); handlerName = '<inline>'; } catch { handlerFn = null; }
|
|
741
923
|
}
|
|
742
924
|
}
|
|
743
925
|
R.handler = handlerFn ? { kind: handlerName === '<inline>' ? 'inline' : 'named', name: handlerName } : { kind: 'unknown', name: handlerName };
|
|
@@ -851,7 +1033,7 @@ function emitFindings(ctx, result, yesodAuthz) {
|
|
|
851
1033
|
if (!R.handler || R.handler.kind === 'unknown') continue;
|
|
852
1034
|
const writes = WRITE_METHODS.has(R.method) || R.method === 'ANY';
|
|
853
1035
|
const privileged = ADMIN_PATH.test(R.path) || /admin/i.test(R.handler.name || '');
|
|
854
|
-
if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
|
|
1036
|
+
if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: R.auth.evidence[0].order === 'unknown' ? `the rejection ${R.auth.evidence[0].callee} cannot be shown to run before the first sensitive operation (${R.auth.evidence[0].reason})` : `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
|
|
855
1037
|
else if (R.auth.status === 'none' && (writes || privileged) && R.sensitive !== false) {
|
|
856
1038
|
if (!(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: R.method === 'ANY' ? 'any method' : 'state-changing method' });
|
|
857
1039
|
}
|
package/src/language/nix-fix.js
CHANGED
|
@@ -340,7 +340,7 @@ function makeEffectiveCheck(entry) {
|
|
|
340
340
|
export async function validateNixFix(finding, o) {
|
|
341
341
|
const plan = planNixFix(finding, o.files, o);
|
|
342
342
|
if (!plan.ok) return { status: plan.status || 'unsupported', applied: false, tier: plan.tier, reason: plan.reason, proposal: plan.proposal || null, ...plan };
|
|
343
|
-
const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, effectiveCheck: makeEffectiveCheck(entryOf(o.files, o)), writeFile: o.writeFile, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
|
|
343
|
+
const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, effectiveCheck: makeEffectiveCheck(entryOf(o.files, o)), writeFile: o.writeFile, preWrite: o.preWrite, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
|
|
344
344
|
return { ...res, consequences: plan.consequences || [], tier: res.status === 'blocked' ? TIERS.blocked : plan.tier, explanation: plan.explanation };
|
|
345
345
|
}
|
|
346
346
|
|
|
@@ -69,13 +69,16 @@ export function resolvedHackageComponents(files) {
|
|
|
69
69
|
const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
|
|
70
70
|
const direct = new Set((g.declared || []).map((d) => d.name));
|
|
71
71
|
const components = [];
|
|
72
|
+
// Boot libraries are not dependencies to match, but the plan states the exact version the compiler provides: the compiler-advisory
|
|
73
|
+
// grouping decides a boot-library advisory from it (and from the compiler-id) instead of from a declared range.
|
|
74
|
+
const bootLibraries = (g.units || []).filter((u) => u.origin === 'compiler' && u.name && u.version).map((u) => ({ name: u.name, version: u.version, versionSource: { file: r.file, line: null } }));
|
|
72
75
|
for (const u of g.units) {
|
|
73
76
|
if (u.origin === 'local' || u.origin === 'compiler' || !u.name || !u.version) continue; // the project itself and boot libraries are not dependencies to match
|
|
74
77
|
const scopes = Array.isArray(u.scopes) && u.scopes.length ? u.scopes : ['runtime'];
|
|
75
78
|
const scope = scopes.includes('runtime') ? 'runtime' : scopes[0];
|
|
76
79
|
components.push({ ecosystem: 'hackage', name: u.name, version: u.version, declaredRange: null, resolution: 'plan', scope, engineScope: SCOPE[scope] || 'required', target: null, componentKind: u.componentKind || null, manifest: r.file, line: null, ghcComponent: false, direct: direct.has(u.name), unitId: u.id, origin: u.origin, versionSource: { file: r.file, line: null } });
|
|
77
80
|
}
|
|
78
|
-
return { components, gaps, summary };
|
|
81
|
+
return { components, bootLibraries, compiler: g.compiler || null, gaps, summary };
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
// ── Nix closure ──────────────────────────────────────────────────────────────────
|
package/src/language/witness.js
CHANGED
|
@@ -92,7 +92,7 @@ export function runNixScriptWitness(w, { timeoutMs = DEFAULT_TIMEOUT_MS, force }
|
|
|
92
92
|
// 1. a witness that does not parse proves nothing in either direction
|
|
93
93
|
const syn = runConfined([shellBin, '-n', 'witness.sh'], { root, timeoutMs, force });
|
|
94
94
|
if (syn.status === 'disabled' || syn.status === 'error') return { ...base, backend: syn.backend, status: 'invalid', reason: `the sandbox could not run the syntax check (${syn.backend}): ${String(syn.stderr || '').trim() || 'no detail'}` };
|
|
95
|
-
if (syn.timedOut) return { ...base, backend: syn.backend, status: 'invalid', reason: 'the syntax check exceeded its time budget' };
|
|
95
|
+
if (syn.timedOut) return { ...base, backend: syn.backend, status: 'invalid', reason: 'the syntax check exceeded its time budget', timedOut: true };
|
|
96
96
|
if (syn.exitCode !== 0) return { ...base, backend: syn.backend, status: 'invalid', reason: `syntax error in the generated witness: ${String(syn.stderr || '').trim().split('\n')[0] || 'shell rejected it'}` };
|
|
97
97
|
// 2. the real run
|
|
98
98
|
const r = runConfined([shellBin, 'witness.sh'], { root, timeoutMs, force });
|