@clear-capabilities/agentic-security-scanner 0.158.0 → 0.160.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 +39 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/3531.index.js +1 -1
- package/dist/4171.index.js +4 -4
- package/dist/{8551.index.js → 7033.index.js} +415 -22
- package/dist/9560.index.js +3 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +11 -6
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +48 -1
- package/src/language/assurance.js +21 -4
- package/src/language/context.js +2 -0
- package/src/language/cvss.js +91 -0
- package/src/language/fix-lifecycle.js +114 -21
- package/src/language/haskell-adapter.js +4 -3
- package/src/language/haskell-advisory-feed.js +15 -0
- package/src/language/haskell-boundaries.js +220 -0
- package/src/language/haskell-cpp.js +460 -0
- package/src/language/haskell-ir.js +6 -3
- package/src/language/haskell-models.js +4 -3
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +10 -6
- package/src/language/haskell-supply.js +42 -8
- package/src/language/haskell-web.js +128 -23
- package/src/language/nix-advisory-feed.js +275 -0
- package/src/language/nix-fix.js +162 -24
- package/src/language/nix-ir.js +16 -12
- package/src/language/nix-sca.js +71 -12
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-module-resolver.js +346 -51
- package/src/language/resolved-pass.js +57 -3
- package/src/lsp/server.js +5 -2
- package/src/mcp/tools.js +3 -1
- package/src/posture/fix-history.js +30 -3
- package/src/report/index.js +4 -1
- package/dist/1310.index.js +0 -3161
- package/dist/2376.index.js +0 -3151
- package/dist/3526.index.js +0 -1019
- package/dist/7310.index.js +0 -520
- package/dist/8520.index.js +0 -13
- package/dist/9091.index.js +0 -3135
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
// named and no partial analysis, and never throws into the caller.
|
|
26
26
|
|
|
27
27
|
import { loadHaskellGrammar } from './haskell-grammar.js';
|
|
28
|
+
import { evaluateCppLines } from './haskell-cpp.js';
|
|
29
|
+
import { classifySafeSplice, classifyInertQuasiQuote } from './haskell-boundaries.js';
|
|
28
30
|
|
|
29
31
|
export const DEFAULT_PARSE_BUDGETS = Object.freeze({
|
|
30
32
|
maxBytes: 4 * 1024 * 1024,
|
|
@@ -36,6 +38,10 @@ export const DEFAULT_PARSE_BUDGETS = Object.freeze({
|
|
|
36
38
|
maxErrors: 200,
|
|
37
39
|
});
|
|
38
40
|
|
|
41
|
+
// Boundaries that are disclosed but do not make a file incomplete: the content was either parsed (cpp, ffi) or matched
|
|
42
|
+
// by a closed, sound pattern and stated as an assumption (see haskell-boundaries.js).
|
|
43
|
+
export const NON_OPAQUE_BOUNDARIES = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert']);
|
|
44
|
+
|
|
39
45
|
class BudgetError extends Error {
|
|
40
46
|
constructor(budget, limit) {
|
|
41
47
|
super(`parser budget "${budget}" exceeded (limit ${limit})`);
|
|
@@ -157,48 +163,55 @@ function unwrapLiterate(source) {
|
|
|
157
163
|
return { code: out, style: latex ? 'latex' : 'bird' };
|
|
158
164
|
}
|
|
159
165
|
|
|
160
|
-
function stripDirectives(code, dialect, grammar, loc) {
|
|
166
|
+
function stripDirectives(code, dialect, grammar, loc, cppCtx) {
|
|
161
167
|
const cppNames = new Set(grammar.cppDirectives);
|
|
162
168
|
const lines = code.split('\n');
|
|
163
169
|
const directives = [];
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
let
|
|
170
|
+
// Physical line offsets into the ORIGINAL text, so directive spans stay exact.
|
|
171
|
+
const starts = new Array(lines.length);
|
|
172
|
+
{ let o = 0; for (let i = 0; i < lines.length; i++) { starts[i] = o; o += lines[i].length + 1; } }
|
|
173
|
+
// Group directive lines (a trailing backslash continues the directive onto the next physical line).
|
|
174
|
+
const groups = new Map();
|
|
167
175
|
for (let i = 0; i < lines.length; i++) {
|
|
168
|
-
|
|
176
|
+
const line = lines[i];
|
|
169
177
|
const m = /^#\s*([A-Za-z]+)/.exec(line);
|
|
170
178
|
const shebang = i === 0 && line.startsWith('#!');
|
|
171
179
|
const hscLine = dialect === 'hsc' && /^#/.test(line);
|
|
172
|
-
if (shebang || hscLine || (m && cppNames.has(m[1])))
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
180
|
+
if (!(shebang || hscLine || (m && cppNames.has(m[1])))) continue;
|
|
181
|
+
const name = shebang ? 'shebang' : (m ? m[1] : 'hsc');
|
|
182
|
+
let k = i;
|
|
183
|
+
let text = line;
|
|
184
|
+
while (/\\\r?$/.test(lines[k]) && k + 1 < lines.length) { k++; text += ' ' + lines[k].replace(/\r$/, ''); }
|
|
185
|
+
groups.set(i, { name, text: text.replace(/\\\r?(?= )/g, ' '), last: k });
|
|
186
|
+
directives.push({ name, line: i + 1, span: loc.span(starts[i], starts[i] + line.length) });
|
|
187
|
+
i = k;
|
|
188
|
+
}
|
|
189
|
+
let evalOut = null;
|
|
190
|
+
let depthAtLine = new Int32Array(lines.length + 2);
|
|
191
|
+
let outLines = lines;
|
|
192
|
+
if (dialect !== 'hsc' && groups.size) {
|
|
193
|
+
evalOut = evaluateCppLines(lines, (i) => { const g = groups.get(i); return g && g.name !== 'shebang' ? g : null; }, cppCtx || {});
|
|
194
|
+
outLines = evalOut.lines.slice();
|
|
195
|
+
depthAtLine = evalOut.maybeDepth;
|
|
196
|
+
} else {
|
|
197
|
+
outLines = lines.slice();
|
|
198
|
+
let depth = 0;
|
|
199
|
+
for (let i = 0; i < lines.length; i++) {
|
|
200
|
+
const g = groups.get(i);
|
|
201
|
+
if (g) {
|
|
202
|
+
if (g.name === 'if' || g.name === 'ifdef' || g.name === 'ifndef') depth++;
|
|
203
|
+
else if (g.name === 'endif' && depth > 0) depth--;
|
|
204
|
+
for (let q = i; q <= g.last; q++) depthAtLine[q + 1] = depth;
|
|
205
|
+
i = g.last;
|
|
206
|
+
} else {
|
|
207
|
+
depthAtLine[i + 1] = depth;
|
|
208
|
+
if (dialect === 'hsc') outLines[i] = lines[i].replace(/#\{[^}\n]*\}/g, (x) => ' '.repeat(x.length));
|
|
192
209
|
}
|
|
193
|
-
lines[i] = cur;
|
|
194
|
-
line = cur;
|
|
195
|
-
} else {
|
|
196
|
-
depthAtLine[i + 1] = depth;
|
|
197
|
-
if (dialect === 'hsc') lines[i] = line.replace(/#\{[^}\n]*\}/g, (s) => ' '.repeat(s.length));
|
|
198
210
|
}
|
|
199
|
-
off += line.length + 1;
|
|
200
211
|
}
|
|
201
|
-
|
|
212
|
+
for (const [i, g] of groups) for (let q = i; q <= g.last; q++) outLines[q] = blank(lines[q]);
|
|
213
|
+
const cpp = evalOut ? { decided: evalOut.decided, undecided: evalOut.undecided, deadLines: evalOut.deadLines } : { decided: [], undecided: [], deadLines: 0 };
|
|
214
|
+
return { code: outLines.join('\n'), directives, depthAtLine, cpp };
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
// ── lexer ──────────────────────────────────────────────────────────────────
|
|
@@ -436,7 +449,7 @@ function emptyResult(file, dialect) {
|
|
|
436
449
|
return {
|
|
437
450
|
language: 'haskell', file, dialect, ok: false, status: 'failed', complete: false,
|
|
438
451
|
module: null, imports: [], functions: [], signatures: [], declarations: [], calls: [], expressions: [],
|
|
439
|
-
records: [], doBlocks: [], comments: [], pragmas: [], errors: [], boundaries: [], uncertainty: [], gaps: [],
|
|
452
|
+
records: [], doBlocks: [], comments: [], pragmas: [], errors: [], boundaries: [], blankRanges: [], uncertainty: [], gaps: [],
|
|
440
453
|
stats: { bytes: 0, tokens: 0, steps: 0 },
|
|
441
454
|
};
|
|
442
455
|
}
|
|
@@ -462,7 +475,7 @@ export function parseHaskell(source, opts = {}) {
|
|
|
462
475
|
}
|
|
463
476
|
const started = Date.now();
|
|
464
477
|
const ctx = {
|
|
465
|
-
grammar: g.grammar, budgets, steps: 0,
|
|
478
|
+
grammar: g.grammar, budgets, steps: 0, cpp: opts.cpp || null,
|
|
466
479
|
tick() {
|
|
467
480
|
this.steps++;
|
|
468
481
|
if (this.steps > budgets.maxSteps) throw new BudgetError('maxSteps', budgets.maxSteps);
|
|
@@ -475,7 +488,7 @@ export function parseHaskell(source, opts = {}) {
|
|
|
475
488
|
res.stats.steps = ctx.steps;
|
|
476
489
|
res.status = res.errors.length ? 'parsed_with_errors' : 'parsed';
|
|
477
490
|
res.ok = true;
|
|
478
|
-
res.complete = res.errors.length === 0 && !res.boundaries.some((b) =>
|
|
491
|
+
res.complete = res.errors.length === 0 && !res.boundaries.some((b) => !NON_OPAQUE_BOUNDARIES.has(b.kind));
|
|
479
492
|
} catch (e) {
|
|
480
493
|
const fresh = emptyResult(file, dialect);
|
|
481
494
|
fresh.stats = { ...res.stats, steps: ctx.steps };
|
|
@@ -503,11 +516,18 @@ function analyze(source, file, dialect, ctx, res) {
|
|
|
503
516
|
code = lit.code;
|
|
504
517
|
res.dialect = lit.style === 'latex' ? 'literate-latex' : 'literate-bird';
|
|
505
518
|
}
|
|
506
|
-
const pre = stripDirectives(code, dialect, g, loc);
|
|
519
|
+
const pre = stripDirectives(code, dialect, g, loc, ctx.cpp);
|
|
507
520
|
code = pre.code;
|
|
508
521
|
const cppLines = pre.directives.filter((d) => d.name !== 'shebang');
|
|
522
|
+
// The preprocessed text (directives and dead branches overwritten in place) for the semantic IR; not serialised with the result.
|
|
523
|
+
Object.defineProperty(res, '_preprocessed', { value: code, enumerable: false, configurable: true });
|
|
509
524
|
if (cppLines.length && dialect !== 'hsc') {
|
|
510
|
-
|
|
525
|
+
const cd = pre.cpp;
|
|
526
|
+
const parts = [`${cppLines.length} preprocessor line(s) removed in place`];
|
|
527
|
+
if (cd.decided.length) parts.push(`${cd.decided.length} conditional(s) decided (${cd.deadLines} dead line(s) overwritten with spaces)`);
|
|
528
|
+
parts.push(cd.undecided.length ? `${cd.undecided.length} conditional(s) could not be decided, so every possible branch was parsed` : 'no conditional was left undecided');
|
|
529
|
+
const assumptions = [...new Set(cd.decided.flatMap((d) => (d.basis || []).filter((b) => b !== 'file')))].sort();
|
|
530
|
+
res.boundaries.push({ kind: 'cpp', span: cppLines[0].span, detail: parts.join('; '), count: cppLines.length, decided: cd.decided.length, undecided: cd.undecided.length, deadLines: cd.deadLines, assumptions, decisions: cd.decided, undecidedAt: cd.undecided.map((u) => u.line) });
|
|
511
531
|
}
|
|
512
532
|
if (dialect === 'hsc') {
|
|
513
533
|
res.boundaries.push({ kind: 'hsc', span: loc.span(0, Math.min(source.length, 1)), detail: 'hsc2hs source: # directives and #{...} blanked in place, never run through hsc2hs' });
|
|
@@ -652,6 +672,7 @@ function analyze(source, file, dialect, ctx, res) {
|
|
|
652
672
|
const spanOf = (a, b) => loc.span(T[a].s, T[Math.max(a, b - 1)].e);
|
|
653
673
|
const condAt = (t) => pre.depthAtLine[t.line] > 0;
|
|
654
674
|
const topRanges = [];
|
|
675
|
+
const spliceItems = [];
|
|
655
676
|
|
|
656
677
|
const registerFunction = (key, rec) => {
|
|
657
678
|
let f = fnIndex.get(key);
|
|
@@ -865,7 +886,9 @@ function analyze(source, file, dialect, ctx, res) {
|
|
|
865
886
|
}
|
|
866
887
|
if (mark < 0) {
|
|
867
888
|
if (c.scope === 'top' && ctx.th) {
|
|
868
|
-
|
|
889
|
+
const bnd = { kind: 'th-top-level-splice', span: spanOf(a, b), detail: 'top-level Template Haskell splice; declarations it generates are not visible' };
|
|
890
|
+
res.boundaries.push(bnd);
|
|
891
|
+
spliceItems.push({ a, b, bnd });
|
|
869
892
|
return;
|
|
870
893
|
}
|
|
871
894
|
errors.push({ kind: 'expected-declaration', offset: T[a].s, endOffset: T[b - 1].e, detail: 'item is neither a binding nor a signature' });
|
|
@@ -1022,6 +1045,41 @@ function analyze(source, file, dialect, ctx, res) {
|
|
|
1022
1045
|
else scanItem(w.role, w.a, w.b, w);
|
|
1023
1046
|
}
|
|
1024
1047
|
|
|
1048
|
+
function absorbSafeBoundaries() {
|
|
1049
|
+
if (!spliceItems.length && !res.boundaries.some((x) => x.kind === 'quasiquote')) return;
|
|
1050
|
+
const defined = new Set();
|
|
1051
|
+
for (const f of res.functions) if (f.name) defined.add(f.name);
|
|
1052
|
+
for (const d of res.declarations) if (d.name) defined.add(d.name);
|
|
1053
|
+
for (const sg of res.signatures) for (const n of sg.names) defined.add(n);
|
|
1054
|
+
const absorbed = [];
|
|
1055
|
+
for (const it of spliceItems) {
|
|
1056
|
+
const r = classifySafeSplice(T, it.a, it.b, match, res.imports, defined);
|
|
1057
|
+
if (!r) continue;
|
|
1058
|
+
const from = T[it.a].s; const to = T[it.b - 1].e;
|
|
1059
|
+
absorbed.push({ from, to });
|
|
1060
|
+
Object.assign(it.bnd, {
|
|
1061
|
+
kind: 'th-safe-splice',
|
|
1062
|
+
detail: `declaration-level splice of ${[...new Set(r.generators)].join(', ')}: names and literals only, no project-controlled string reaches it; ASSUMED to be the upstream generator from ${r.modules.join(' / ')}, and the declarations it generates are not modelled`,
|
|
1063
|
+
generators: [...new Set(r.generators)], assumption: 'upstream-generator',
|
|
1064
|
+
});
|
|
1065
|
+
res.blankRanges.push({ kind: 'th-safe-splice', startOffset: from, endOffset: to });
|
|
1066
|
+
}
|
|
1067
|
+
const inside = (b) => absorbed.some((x) => b.span.startOffset >= x.from && b.span.endOffset <= x.to);
|
|
1068
|
+
res.boundaries = res.boundaries.filter((b) => !((b.kind === 'th-name-quote' || b.kind === 'th-splice' || b.kind === 'quasiquote') && inside(b)));
|
|
1069
|
+
for (const b of res.boundaries) {
|
|
1070
|
+
if (b.kind !== 'quasiquote') continue;
|
|
1071
|
+
const body = source.slice(b.span.startOffset, b.span.endOffset);
|
|
1072
|
+
const bar = body.indexOf('|');
|
|
1073
|
+
const inner = bar < 0 ? '' : body.slice(bar + 1, body.endsWith('|]') ? -2 : undefined);
|
|
1074
|
+
const r = classifyInertQuasiQuote(b.quoter, inner, res.imports, defined);
|
|
1075
|
+
if (!r) continue;
|
|
1076
|
+
b.kind = 'quasiquote-inert';
|
|
1077
|
+
b.detail = `quasi-quote [${r.quoter}|...|] is a string literal (no interpolation); ASSUMED to be the upstream quoter from ${r.modules.join(' / ')}`;
|
|
1078
|
+
b.assumption = 'upstream-quoter';
|
|
1079
|
+
res.blankRanges.push({ kind: 'quasiquote-inert', startOffset: b.span.startOffset, endOffset: b.span.endOffset });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1025
1083
|
// ── errors → withheld calls ──
|
|
1026
1084
|
const bad = [];
|
|
1027
1085
|
for (const e of errors) {
|
|
@@ -1063,6 +1121,9 @@ function analyze(source, file, dialect, ctx, res) {
|
|
|
1063
1121
|
}
|
|
1064
1122
|
if (total > outErrors.length) res.errors.push({ kind: 'errors-truncated', detail: `${total - outErrors.length} further syntax error(s) not listed` });
|
|
1065
1123
|
|
|
1124
|
+
// ── safe generative splices and inert quasi-quotes (disclosed assumptions, not opaque boundaries) ──
|
|
1125
|
+
absorbSafeBoundaries();
|
|
1126
|
+
|
|
1066
1127
|
// ── uncertainty ──
|
|
1067
1128
|
if (res.boundaries.some((x) => x.kind === 'cpp' || x.kind === 'hsc')) res.uncertainty.push({ kind: 'preprocessed' });
|
|
1068
1129
|
if (res.boundaries.some((x) => x.kind === 'generated' || x.kind.startsWith('th-') || x.kind === 'quasiquote')) res.uncertainty.push({ kind: 'generated-source' });
|
|
@@ -1071,3 +1132,26 @@ function analyze(source, file, dialect, ctx, res) {
|
|
|
1071
1132
|
res.calls.sort((x, y) => x.span.startOffset - y.span.startOffset);
|
|
1072
1133
|
void cut;
|
|
1073
1134
|
}
|
|
1135
|
+
|
|
1136
|
+
const IR_PREPROCESS_GUARD = /^[ \t]*#\s*(?:if|ifdef|ifndef|elif|else|endif|define|undef)\b|\bTemplateHaskell\b|\bQuasiQuotes\b/m;
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Source for the semantic IR's own parser, which knows neither CPP nor Template Haskell. Length-preserving: CPP directives and
|
|
1140
|
+
* dead branches become spaces; a safe declaration splice becomes spaces; an inert quasi-quote becomes a string literal of the
|
|
1141
|
+
* same length. Anything else (an undecided conditional's branches, an opaque splice or quasi-quote) is left exactly as written.
|
|
1142
|
+
* Returns the input unchanged when it has nothing to preprocess or cannot be parsed.
|
|
1143
|
+
*/
|
|
1144
|
+
export function preprocessForSyntax(source, opts = {}) {
|
|
1145
|
+
if (typeof source !== 'string' || !IR_PREPROCESS_GUARD.test(source)) return source;
|
|
1146
|
+
let r;
|
|
1147
|
+
try { r = parseHaskell(source, { file: opts.file, cpp: opts.cpp }); } catch { return source; }
|
|
1148
|
+
if (!r || !r.ok || typeof r._preprocessed !== 'string' || r._preprocessed.length !== source.length) return source;
|
|
1149
|
+
let out = r._preprocessed;
|
|
1150
|
+
for (const b of r.blankRanges || []) {
|
|
1151
|
+
const seg = out.slice(b.startOffset, b.endOffset);
|
|
1152
|
+
let repl = blank(seg);
|
|
1153
|
+
if (b.kind === 'quasiquote-inert' && repl.length >= 2) repl = `"${repl.slice(1, -1)}"`;
|
|
1154
|
+
out = out.slice(0, b.startOffset) + repl + out.slice(b.endOffset);
|
|
1155
|
+
}
|
|
1156
|
+
return out;
|
|
1157
|
+
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// response). The wire query is built here so its exact shape is testable.
|
|
20
20
|
|
|
21
21
|
import { createHash, createVerify } from 'node:crypto';
|
|
22
|
+
import { advisorySeverity, NO_RATING_BASIS } from './cvss.js';
|
|
22
23
|
import { parseVersion, compareVersions, parseVersionRange, caretUpperBound } from './haskell-manifests.js';
|
|
23
24
|
|
|
24
25
|
export const HS_SCA_VERSION = 'haskell-sca/1';
|
|
@@ -140,7 +141,7 @@ export function normalizeAdvisory(rec) {
|
|
|
140
141
|
id: rec.id, canonicalId, ids, cveAliases: ids.filter((x) => /^CVE-/.test(x)), ghsaAliases: ids.filter((x) => /^GHSA-/.test(x)),
|
|
141
142
|
summary: rec.summary || '', details: rec.details || '', published: rec.published || null, modified: rec.modified || null,
|
|
142
143
|
withdrawn: rec.withdrawn || null, references: (rec.references || []).map((r) => r.url).filter(Boolean),
|
|
143
|
-
severity: rec.severity || [], affected, problems,
|
|
144
|
+
severity: rec.severity || [], severityInfo: advisorySeverity(rec), affected, problems,
|
|
144
145
|
};
|
|
145
146
|
}
|
|
146
147
|
const fixedVersions = (ranges) => [...new Set((ranges || []).flatMap((r) => (r.events || []).filter((e) => 'fixed' in e).map((e) => e.fixed)))];
|
|
@@ -231,14 +232,16 @@ export function matchComponent(aff, comp) {
|
|
|
231
232
|
if (inside === null) return { status: 'unknown', reason: `"${comp.version}" is not a Cabal version` };
|
|
232
233
|
return inside ? { status: 'affected', reason: 'the resolved version is inside an affected range' } : { status: 'not-affected', reason: 'the resolved version is outside every affected range' };
|
|
233
234
|
}
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
// `unbounded` = the manifest declared the dependency with NO version bound (`build-depends: aeson`), which in Cabal means "any version".
|
|
236
|
+
// That is a declared range like any other (it overlaps an advisory, so possibly-affected), not an absent one.
|
|
237
|
+
if (comp.declaredRange || comp.unbounded) {
|
|
238
|
+
const declared = comp.declaredRange ? rangeToIntervals(comp.declaredRange) : [iv(null, false, null, false)];
|
|
236
239
|
if (declared === null) return { status: 'unknown', reason: 'the declared range could not be parsed' };
|
|
237
240
|
if (!declared.length) return { status: 'unknown', reason: 'the declared range admits no version' };
|
|
238
241
|
const overlap = intervalsOverlap(declared, aff.intervals);
|
|
239
242
|
if (!overlap) return { status: 'not-affected', reason: 'no version allowed by the declared range is affected' };
|
|
240
243
|
const subset = declared.every((d) => aff.intervals.some((a) => !isEmpty(intersectIntervals(d, a)) && containsInterval(a, d)));
|
|
241
|
-
return subset ? { status: 'affected', reason: 'every version allowed by the declared range is affected' } : { status: 'possibly-affected', reason: 'the declared range allows both affected and unaffected versions; no resolved version is known' };
|
|
244
|
+
return subset ? { status: 'affected', reason: 'every version allowed by the declared range is affected' } : { status: 'possibly-affected', reason: comp.declaredRange ? 'the declared range allows both affected and unaffected versions; no resolved version is known' : 'the dependency is declared with no version bound, so any version may be selected; no resolved version is known' };
|
|
242
245
|
}
|
|
243
246
|
return { status: 'unknown', reason: 'neither a resolved version nor a declared range is known' };
|
|
244
247
|
}
|
|
@@ -275,14 +278,15 @@ export function evaluateComponents(components, db, opts = {}) {
|
|
|
275
278
|
statuses.push({ ...base, status: ghc && m.status !== 'not-affected' ? `ghc-component:${m.status}` : m.status, advisory: adv.canonicalId, reason: m.reason, ghcComponent: ghc });
|
|
276
279
|
if (m.status === 'not-affected') continue;
|
|
277
280
|
any = true;
|
|
281
|
+
const sev = adv.severityInfo || { level: null, score: null, basis: NO_RATING_BASIS };
|
|
278
282
|
const kevHit = adv.cveAliases.length ? (opts.kev ? adv.cveAliases.some((c) => opts.kev.has(c)) : 'unknown') : 'not-applicable';
|
|
279
283
|
const epssVals = adv.cveAliases.map((c) => opts.epss && opts.epss[c]).filter((x) => typeof x === 'number');
|
|
280
284
|
findings.push({
|
|
281
285
|
type: 'vulnerable_dep', ecosystem: 'hackage', name: comp.name, version: comp.version || null, declaredRange: comp.declaredRange || null,
|
|
282
286
|
osvId: adv.canonicalId, ids: adv.ids, cveAliases: adv.cveAliases, ghsaAliases: adv.ghsaAliases,
|
|
283
287
|
summary: adv.summary, fixedIn: aff.fixedIn, unfixed: aff.unfixed, references: adv.references.slice(0, 5),
|
|
284
|
-
severity: 'medium', severityBasis:
|
|
285
|
-
matchStatus: m.status, matchReason: m.reason, resolution: comp.version ? 'resolved' : (comp.declaredRange ? 'declared-range' : 'none'),
|
|
288
|
+
severity: sev.level || 'medium', severityBasis: sev.basis, severityScore: sev.score ?? null,
|
|
289
|
+
matchStatus: m.status, matchReason: m.reason, resolution: comp.version ? 'resolved' : (comp.declaredRange ? 'declared-range' : (comp.unbounded ? 'declared-unbounded' : 'none')),
|
|
286
290
|
scope: comp.scope || null, target: comp.target || null, purl: base.purl, ghcComponent: ghc,
|
|
287
291
|
...(ghc ? { remediation: `${comp.name} is provided by the compiler: upgrade GHC (the fix is in ${comp.name} ${aff.fixedIn.join(', ') || '(no fixed version published)'}), not a Cabal dependency bound.` } : { remediation: aff.fixedIn.length ? `Upgrade ${comp.name} to ${aff.fixedIn.join(' or ')}.` : `No fixed version of ${comp.name} is published; remove or replace the dependency.` }),
|
|
288
292
|
kev: kevHit, epss: epssVals.length ? Math.max(...epssVals) : 'unknown',
|
|
@@ -21,22 +21,56 @@ export function hackageComponents(files) {
|
|
|
21
21
|
const mf = manifestFiles(files);
|
|
22
22
|
if (!mf.length) return { components: [], manifests: null };
|
|
23
23
|
const manifests = analyzeHaskellManifests(mf);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// A freeze or lock file resolves the manifests at or below its own directory (that is what a Cabal project or Stack project root means), and the
|
|
25
|
+
// nearest enclosing one wins. Applying one repository-wide made a release builder's freeze (builders/linux.armv6hf/) the "resolved" version of
|
|
26
|
+
// the root package: found by the live Hackage feed bench.
|
|
27
|
+
const dirOf = (p) => (typeof p === 'string' && p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
|
|
28
|
+
const covers = (lockFile, manifest) => { const base = dirOf(lockFile); const m = dirOf(manifest); return base === '' || m === base || m.startsWith(`${base}/`); };
|
|
29
|
+
const lockedFor = (name, manifest) => {
|
|
30
|
+
let best = null;
|
|
31
|
+
for (const l of manifests.lockedPackages || []) {
|
|
32
|
+
if (l.name !== name || !l.version || !l.file || !covers(l.file, manifest)) continue;
|
|
33
|
+
if (!best || dirOf(l.file).length >= dirOf(best.file).length) best = l;
|
|
34
|
+
}
|
|
35
|
+
return best;
|
|
36
|
+
};
|
|
37
|
+
// Packages this project DEFINES (a .cabal file or an hpack package.yaml in the scanned tree). A `build-depends` on one of them resolves to the
|
|
38
|
+
// workspace package, not to Hackage; reporting it would raise advisories about the project's own (usually fixed) version as if it were a
|
|
39
|
+
// dependency. Found by the live Hackage feed bench (aeson's own test suite depends on aeson).
|
|
40
|
+
const defined = new Set([...(manifests.packages || []), ...(manifests.hpack || [])].map((p) => p.name).filter(Boolean));
|
|
27
41
|
const seen = new Map();
|
|
28
42
|
for (const d of manifests.dependencies || []) {
|
|
29
|
-
|
|
30
|
-
const
|
|
43
|
+
if (defined.has(d.name)) continue;
|
|
44
|
+
const lock = lockedFor(d.name, d.manifest);
|
|
45
|
+
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
|
|
31
47
|
if (seen.has(key)) continue;
|
|
32
48
|
seen.set(key, {
|
|
33
|
-
ecosystem: 'hackage', name: d.name, version: resolved, declaredRange: d.declaredRange || null,
|
|
34
|
-
resolution:
|
|
49
|
+
ecosystem: 'hackage', name: d.name, version: resolved, declaredRange: d.declaredRange || null, unbounded: !resolved && !d.declaredRange && d.rangeKind === 'unbounded',
|
|
50
|
+
resolution: lock ? 'freeze' : (d.exactPin ? 'exact-pin' : 'unresolved'),
|
|
35
51
|
scope: d.scope || 'runtime', engineScope: SCOPE[d.scope] || 'required', target: d.component || null, componentKind: d.componentKind || null,
|
|
36
52
|
manifest: d.manifest, line: d.line, ghcComponent: GHC_BOOT_PACKAGES.has(d.name),
|
|
37
|
-
versionSource:
|
|
53
|
+
versionSource: lock ? { file: lock.file, line: lock.line || null } : null,
|
|
38
54
|
});
|
|
39
55
|
}
|
|
56
|
+
// Cabal chooses ONE version of a package for every component of a .cabal file, so a use with no bound inherits the unconditional bounds the
|
|
57
|
+
// same manifest declares for that package elsewhere (a library that pins `aeson >=2.2.5.1` next to a test suite that just says `aeson`).
|
|
58
|
+
// Conditional bounds (flags, os, compiler) are not in force everywhere and are not inherited; neither is a bound from another manifest.
|
|
59
|
+
const boundsIn = new Map();
|
|
60
|
+
for (const d of manifests.dependencies || []) {
|
|
61
|
+
if (!d.declaredRange || (d.conditions && d.conditions.length) || defined.has(d.name)) continue;
|
|
62
|
+
const k = `${d.manifest}\u0000${d.name}`;
|
|
63
|
+
if (!boundsIn.has(k)) boundsIn.set(k, []);
|
|
64
|
+
if (!boundsIn.get(k).includes(d.declaredRange)) boundsIn.get(k).push(d.declaredRange);
|
|
65
|
+
}
|
|
66
|
+
for (const c of seen.values()) {
|
|
67
|
+
if (!c.unbounded) continue;
|
|
68
|
+
const rs = boundsIn.get(`${c.manifest}\u0000${c.name}`);
|
|
69
|
+
if (!rs) continue;
|
|
70
|
+
c.declaredRange = rs.length === 1 ? rs[0] : rs.map((r) => `(${r})`).join(' && ');
|
|
71
|
+
c.unbounded = false;
|
|
72
|
+
c.rangeInheritedFromSibling = true;
|
|
73
|
+
}
|
|
40
74
|
return { components: [...seen.values()], manifests };
|
|
41
75
|
}
|
|
42
76
|
|
|
@@ -36,7 +36,7 @@ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
36
36
|
const SCOTTY_METHODS = { get: 'GET', post: 'POST', put: 'PUT', delete: 'DELETE', patch: 'PATCH', options: 'OPTIONS', matchAny: 'ANY', addroute: 'ANY' };
|
|
37
37
|
const SENSITIVE_PREFIX = ['Database.', 'System.Process.', 'System.Directory.', 'Prelude.readFile', 'Prelude.writeFile', 'Prelude.appendFile', 'System.IO.', 'Network.HTTP.Simple.', 'Network.HTTP.Client.'];
|
|
38
38
|
const PERSIST_FNS = new Set(['get', 'getBy', 'selectFirst', 'selectList', 'insert', 'insert_', 'update', 'updateWhere', 'delete', 'deleteWhere', 'replace', 'runDB', 'getJust', 'upsert']);
|
|
39
|
-
const GUARD_PRIMITIVES = new Set(['Yesod.Core.requireAuthId', 'Yesod.Core.requireAuth', 'Yesod.Auth.requireAuthId', 'Yesod.Auth.requireAuth', 'Network.Wai.Middleware.HttpAuth.basicAuth']);
|
|
39
|
+
const GUARD_PRIMITIVES = new Set(['Yesod.Core.requireAuthId', 'Yesod.Core.requireAuth', 'Yesod.Auth.requireAuthId', 'Yesod.Auth.requireAuth', 'Yesod.requireAuthId', 'Yesod.requireAuth', 'Network.Wai.Middleware.HttpAuth.basicAuth']);
|
|
40
40
|
const CREDENTIAL_HEADER = /^(?:authorization|cookie|x-api-key|x-auth-token|x-access-token)$/i;
|
|
41
41
|
const ADMIN_PATH = /(?:^|\/)(?:admin|internal|manage|superuser)(?:\/|$|:)/i;
|
|
42
42
|
const ROLE_CALL = /(?:^|\.)(?:isAdmin|hasRole|requireAdmin|checkRole|hasPermission|requireRole|requirePermission|isSuperuser)$/i;
|
|
@@ -128,31 +128,111 @@ function credentialKind(c) {
|
|
|
128
128
|
if (c.callee === 'Web.Cookie.parseCookies') return 'cookie';
|
|
129
129
|
if (c.callee === 'Network.Wai.requestHeaders') return 'token';
|
|
130
130
|
if (c.callee.startsWith('Web.JWT.')) return 'token';
|
|
131
|
+
// Yesod: the session principal, or a credential header, read by the handler itself (the caller still has to reject on it)
|
|
132
|
+
if (/^Yesod(?:\.Core|\.Auth)?\.(?:maybeAuthId|maybeAuth)$/.test(c.callee)) return 'session';
|
|
133
|
+
if (/^Yesod(?:\.Core)?\.lookupHeader$/.test(c.callee)) { const h = headerArg(c); if (h && CREDENTIAL_HEADER.test(h)) return /cookie/i.test(h) ? 'cookie' : 'token'; }
|
|
134
|
+
if (/^Yesod(?:\.Core)?\.lookupBearerAuth$/.test(c.callee)) return 'token';
|
|
135
|
+
if (/^Yesod(?:\.Core)?\.lookupBasicAuth$/.test(c.callee)) return 'basic';
|
|
131
136
|
if (GUARD_PRIMITIVES.has(c.callee)) return c.callee.includes('HttpAuth') ? 'basic' : 'session';
|
|
132
137
|
return null;
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Credential reads of one function, by call. A WAI `requestHeaders` read is classified by the key it is looked up with: a lookup of a
|
|
142
|
+
* header that is not a credential (`lookup "X-Request-Id" (requestHeaders req)`) is not a credential read. An unrecognised use of
|
|
143
|
+
* `requestHeaders` keeps its old meaning (a token read), so only the provable non-credential lookup is excluded.
|
|
144
|
+
* The IR lowers `lookup k t` to a call on `t` with the key kept as `hs.selector`.
|
|
145
|
+
*/
|
|
146
|
+
function credentialReadsOf(fn) {
|
|
147
|
+
const nodes = Object.values(fn.cfg.nodes);
|
|
148
|
+
const hdrTargets = new Map(); // temp name -> line of the node that read requestHeaders
|
|
149
|
+
for (const n of nodes) if (n.kind === 'assign' && n.source && n.source.kind === 'call' && n.source.callee === 'Network.Wai.requestHeaders') hdrTargets.set(n.target, n.line);
|
|
150
|
+
const lineKind = new Map(); // line of a requestHeaders read -> 'token' | 'cookie' | null (a header that is not a credential)
|
|
151
|
+
for (const n of nodes) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
|
|
152
|
+
if (e.kind !== 'call' || !/(?:^|\.)lookup$/.test(e.callee || '') || !e.hs || !e.hs.selector) continue;
|
|
153
|
+
const sel = e.hs.selector;
|
|
154
|
+
let name = null;
|
|
155
|
+
if (sel.kind === 'literal' && typeof sel.value === 'string') name = sel.value;
|
|
156
|
+
else if (sel.kind === 'ident' && /(?:^|\.)hAuthorization$/.test(sel.name || '')) name = 'Authorization';
|
|
157
|
+
else if (sel.kind === 'ident' && /(?:^|\.)hCookie$/.test(sel.name || '')) name = 'Cookie';
|
|
158
|
+
if (name === null) continue;
|
|
159
|
+
const a0 = (e.args || [])[0];
|
|
160
|
+
const line = a0 && a0.kind === 'ident' && hdrTargets.has(a0.name) ? hdrTargets.get(a0.name) : (a0 && a0.kind === 'call' && a0.callee === 'Network.Wai.requestHeaders' ? n.line : null);
|
|
161
|
+
if (line === null) continue;
|
|
162
|
+
lineKind.set(line, CREDENTIAL_HEADER.test(name) ? (/cookie/i.test(name) ? 'cookie' : 'token') : null);
|
|
163
|
+
}
|
|
164
|
+
const out = new Map();
|
|
165
|
+
for (const c of callsOf(fn)) {
|
|
166
|
+
let k;
|
|
167
|
+
if (c.callee === 'Network.Wai.requestHeaders' && lineKind.has(c.line)) k = lineKind.get(c.line);
|
|
168
|
+
else k = credentialKind(c);
|
|
169
|
+
if (k) out.set(c, k);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `notAuthenticated` takes no argument, so it is an identifier in the IR, not a call (like Scotty's `finish`). It ends the handler by itself,
|
|
176
|
+
* so each occurrence is a rejection, reported as a pseudo-call at its enclosing node's line.
|
|
177
|
+
*/
|
|
178
|
+
function nullaryRejectionsOf(fn) {
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const n of Object.values(fn.cfg.nodes)) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
|
|
181
|
+
if (e.kind === 'ident' && /^Yesod(?:\.Core)?\.notAuthenticated$/.test(e.name || '')) out.push({ kind: 'call', callee: e.name, args: [], line: Number.isFinite(e.line) ? e.line : n.line, hs: { nullary: true } });
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A WAI rejection is a response VALUE: it ends the handler only when it is handed back (`respond (responseLBS 401 ..)`, `return (..)`).
|
|
187
|
+
// Built and dropped, it rejects nothing. Servant's throwError, Yesod's permissionDenied/notAuthenticated and Scotty's raiseStatus end the
|
|
188
|
+
// handler by themselves.
|
|
189
|
+
function stopsHandler(c, allCalls) {
|
|
190
|
+
if (c.callee !== 'Network.Wai.responseLBS') return true;
|
|
191
|
+
return allCalls.some((p) => p !== c && (p.callee === 'return' || p.callee === 'pure' || (p.hs && p.hs.status === 'param') || p.callee === 'respond')
|
|
192
|
+
&& (p.args || []).some((a) => [...walkIr(a)].some((x) => x.kind === 'call' && x.args === c.args)));
|
|
193
|
+
}
|
|
194
|
+
|
|
135
195
|
function isRejection(c) {
|
|
136
196
|
const name = c.callee;
|
|
137
197
|
const argNames = (c.args || []).flatMap((a) => (a && a.kind === 'ident' ? [a.name] : []));
|
|
138
198
|
if ((name === 'Web.Scotty.status' || name === 'Web.Scotty.Trans.status') && argNames.some((n) => /(?:status40[13]|unauthorized401|forbidden403)$/.test(n))) return true;
|
|
199
|
+
if (/^Web\.Scotty(?:\.Trans)?\.raiseStatus$/.test(name) && argNames.some((n) => /(?:status40[13]|unauthorized401|forbidden403)$/.test(n))) return true; // halts by itself
|
|
139
200
|
if (name === 'Network.Wai.responseLBS' && argNames.some((n) => /(?:status40[13]|unauthorized401|forbidden403)$/.test(n))) return true;
|
|
140
|
-
|
|
141
|
-
if (/^
|
|
201
|
+
// `throwError err401 { errBody = .. }` is a record update: the status constructor is inside it
|
|
202
|
+
if (/^(?:Servant|Servant\.Server|Control\.Monad\.Except)\.throwError$/.test(name) && (c.args || []).some((a) => a && [...walkIr(a)].some((x) => (x.kind === 'ident' && /err40[13]$/.test(x.name || '')) || (x.kind === 'call' && /err40[13]$/.test(x.callee || ''))))) return true;
|
|
203
|
+
if (/^Yesod(?:\.Core)?\.(?:permissionDenied|notAuthenticated)$/.test(name)) return true;
|
|
142
204
|
return false;
|
|
143
205
|
}
|
|
144
206
|
|
|
207
|
+
// Scotty's `status` only sets the response code: the handler keeps running unless something halts it.
|
|
208
|
+
const SCOTTY_STATUS = /^Web\.Scotty(?:\.Trans)?\.status$/;
|
|
209
|
+
const SCOTTY_HALT = /^Web\.Scotty(?:\.Trans)?\.(?:finish|raise|raiseStatus|redirect)$/;
|
|
210
|
+
/**
|
|
211
|
+
* Source lines of every Scotty halting point in one function IR. `finish` is usually a bare operand (`status status401 >> finish`,
|
|
212
|
+
* `when bad finish`), which the IR records as an identifier rather than a call, and an identifier has no line of its own: it inherits the
|
|
213
|
+
* enclosing node's line. So both calls and identifiers are collected, by line.
|
|
214
|
+
*/
|
|
215
|
+
function haltLinesOf(fn) {
|
|
216
|
+
const lines = [];
|
|
217
|
+
for (const n of Object.values(fn.cfg.nodes)) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
|
|
218
|
+
const nm = e.kind === 'call' ? e.callee : e.kind === 'ident' ? e.name : null;
|
|
219
|
+
if (nm && SCOTTY_HALT.test(nm)) lines.push(Number.isFinite(e.line) ? e.line : n.line);
|
|
220
|
+
}
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
223
|
+
|
|
145
224
|
function guardSummary(ctx, fn, depth = 0, seen = new Set()) {
|
|
146
225
|
if (!fn) return { auth: false, kinds: [], role: false, owner: false, csrf: false };
|
|
147
226
|
if (ctx.guardMemo.has(fn.qid)) return ctx.guardMemo.get(fn.qid);
|
|
148
227
|
if (seen.has(fn.qid) || depth > 3) return { auth: false, kinds: [], role: false, owner: false, csrf: false };
|
|
149
228
|
seen.add(fn.qid);
|
|
150
229
|
const kinds = new Set();
|
|
151
|
-
let reads = false, rejects = false, role = false, owner = false, csrf = false, primitive = false;
|
|
230
|
+
let reads = false, rejects = false, statusRejects = [], role = false, owner = false, csrf = false, primitive = false;
|
|
231
|
+
const credReads = credentialReadsOf(fn);
|
|
152
232
|
for (const c of callsOf(fn)) {
|
|
153
|
-
const k =
|
|
233
|
+
const k = credReads.get(c) || null;
|
|
154
234
|
if (k) { kinds.add(k); reads = true; if (GUARD_PRIMITIVES.has(c.callee)) primitive = true; }
|
|
155
|
-
if (isRejection(c)) rejects = true;
|
|
235
|
+
if (isRejection(c)) { if (SCOTTY_STATUS.test(c.callee)) statusRejects.push(c.line); else rejects = true; }
|
|
156
236
|
if (ROLE_CALL.test(c.callee || '')) role = true;
|
|
157
237
|
if (OWNER_CALL.test(c.callee || '')) owner = true;
|
|
158
238
|
if (CSRF_CALL.test(c.callee || '')) csrf = true;
|
|
@@ -162,6 +242,11 @@ function guardSummary(ctx, fn, depth = 0, seen = new Set()) {
|
|
|
162
242
|
if (sub.role) role = true; if (sub.owner) owner = true; if (sub.csrf) csrf = true;
|
|
163
243
|
}
|
|
164
244
|
}
|
|
245
|
+
if (!rejects && nullaryRejectionsOf(fn).length) rejects = true;
|
|
246
|
+
// A Scotty `status 40x` is a rejection only if the function also halts the handler (finish, raise, raiseStatus, redirect). Position is not
|
|
247
|
+
// judged here: a bare `finish` operand carries its enclosing node's line (often the function head), so a line comparison would reject
|
|
248
|
+
// the ordinary `Nothing -> status status401 >> finish`. The inline-guard path, which can order against the first sensitive call, does.
|
|
249
|
+
if (!rejects && statusRejects.length && haltLinesOf(fn).length) rejects = true;
|
|
165
250
|
// literal comparisons against an admin role count as a role check
|
|
166
251
|
for (const n of Object.values(fn.cfg.nodes)) for (const e of exprsOfNode(n)) for (const x of walkIr(e)) {
|
|
167
252
|
if (x.kind === 'binary' && (x.op === '==' || x.op === '===' || x.op === '!=')) {
|
|
@@ -188,7 +273,7 @@ const identsIr = (e) => { const out = new Set(); for (const x of walkIr(e)) { if
|
|
|
188
273
|
|
|
189
274
|
// ── handler summary ─────────────────────────────────────────────────────────
|
|
190
275
|
// Ordered call list (program order) + principal / id / ownership facts for one handler function IR.
|
|
191
|
-
function handlerFacts(ctx, fn) {
|
|
276
|
+
function handlerFacts(ctx, fn, opts = {}) {
|
|
192
277
|
const ordered = [];
|
|
193
278
|
const nodes = Object.entries(fn.cfg.nodes).map(([id, n]) => ({ id, ...n })).sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)));
|
|
194
279
|
const assigns = [];
|
|
@@ -226,26 +311,40 @@ function handlerFacts(ctx, fn) {
|
|
|
226
311
|
// a WAI response, a Servant throwError or a Yesod permissionDenied ends the handler by itself.
|
|
227
312
|
// `finish` is usually a bare operand (`status status401 >> finish`, `when bad finish`), which the IR records as an identifier
|
|
228
313
|
// rather than a call, so halting points are collected from both, by source line.
|
|
229
|
-
const
|
|
230
|
-
const haltLines = [];
|
|
231
|
-
for (const n of nodes) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
|
|
232
|
-
if ((e.kind === 'call' ? e.callee : e.kind === 'ident' ? e.name : null) && HALT.test(e.kind === 'call' ? e.callee : e.name)) haltLines.push(Number.isFinite(e.line) ? e.line : n.line);
|
|
233
|
-
}
|
|
314
|
+
const haltLines = haltLinesOf(fn);
|
|
234
315
|
const sensLine = sensIdx >= 0 ? ordered[sensIdx].line : Infinity;
|
|
235
316
|
const haltsAfter = (line) => haltLines.some((h) => Number.isFinite(h) && h >= line && h < sensLine);
|
|
236
317
|
// The complete call list (not the node walk, which does not descend into case alternatives), ordered by source line.
|
|
237
|
-
const allCalls = callsOf(fn).filter((c) => typeof c.callee === 'string' && Number.isFinite(c.line)).sort((x, y) => x.line - y.line);
|
|
318
|
+
const allCalls = [...callsOf(fn), ...nullaryRejectionsOf(fn)].filter((c) => typeof c.callee === 'string' && Number.isFinite(c.line)).sort((x, y) => x.line - y.line);
|
|
319
|
+
const credReads = credentialReadsOf(fn);
|
|
320
|
+
// Credential reads in source order: calls that read one, and handler parameters bound to a credential header (Servant `Header "Authorization"`),
|
|
321
|
+
// whose "read" is their first use in the body.
|
|
322
|
+
const reads = [];
|
|
323
|
+
for (const c of allCalls) if (credReads.has(c)) reads.push({ line: c.line, kind: credReads.get(c), callee: c.callee, call: c });
|
|
324
|
+
for (const cp of opts.credentialParams || []) {
|
|
325
|
+
let first = null;
|
|
326
|
+
for (const n of nodes) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) if (e.kind === 'ident' && e.name === cp.name && Number.isFinite(n.line) && (first === null || n.line < first)) first = n.line;
|
|
327
|
+
if (first !== null) reads.push({ line: first, kind: cp.kind, callee: `param:${cp.name}`, call: null });
|
|
328
|
+
}
|
|
329
|
+
reads.sort((x, y) => x.line - y.line);
|
|
238
330
|
let inlineIdx = -1, inlineKind = null, inlineLine = null, inlineCallee = null;
|
|
239
|
-
for (let i = 0; i <
|
|
240
|
-
const
|
|
241
|
-
if (!
|
|
242
|
-
|
|
331
|
+
for (let i = 0; i < reads.length && inlineIdx < 0; i++) {
|
|
332
|
+
const rd = reads[i];
|
|
333
|
+
if (!(rd.line <= sensLine)) break;
|
|
334
|
+
// (a) a rejection at or after the read and before the first sensitive operation, by line. Scotty's `status` only sets the code, so there a
|
|
335
|
+
// halting call must follow too.
|
|
336
|
+
for (let j = 0; j < allCalls.length && allCalls[j].line < sensLine; j++) {
|
|
243
337
|
const r = allCalls[j];
|
|
244
|
-
if (!isRejection(r)) continue;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
inlineIdx = j; inlineKind = k; inlineLine = r.line; inlineCallee = allCalls[i].callee; break;
|
|
338
|
+
if (r === rd.call || r.line < rd.line || !isRejection(r) || !stopsHandler(r, allCalls)) continue;
|
|
339
|
+
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;
|
|
248
341
|
}
|
|
342
|
+
if (inlineIdx >= 0) break;
|
|
343
|
+
// (b) a rejection that ends the handler by itself (WAI response handed back, throwError, permissionDenied, raiseStatus). A call in tail position
|
|
344
|
+
// (the last statement, a case alternative) reports the enclosing line, not its own, so its line cannot be ordered against the sensitive
|
|
345
|
+
// operation; the ordering that CAN be trusted is the credential read against the first sensitive operation, tested above.
|
|
346
|
+
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; }
|
|
249
348
|
}
|
|
250
349
|
const principals = new Set();
|
|
251
350
|
for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
|
|
@@ -285,7 +384,7 @@ function newRoute(r) { return { auth: { status: 'unknown', kind: null, evidence:
|
|
|
285
384
|
function analyzeRoute(ctx, route, handlerFn, opts) {
|
|
286
385
|
const R = route;
|
|
287
386
|
if (!handlerFn) { R.handler = R.handler || { kind: 'unknown' }; R.auth = { status: 'unknown', kind: null, evidence: [], reason: 'handler not resolved' }; return; }
|
|
288
|
-
const facts = handlerFacts(ctx, handlerFn);
|
|
387
|
+
const facts = handlerFacts(ctx, handlerFn, opts);
|
|
289
388
|
for (const v of opts.principalVars || []) facts.principals.add(v);
|
|
290
389
|
for (const v of opts.idVars || []) facts.idVars.add(v);
|
|
291
390
|
const gs = guardSummary(ctx, handlerFn);
|
|
@@ -460,6 +559,7 @@ function expandChain(chain, acc) {
|
|
|
460
559
|
const name = first.t === 'con' ? first.v : null;
|
|
461
560
|
if (name === 'Capture' || name === 'Capture\'') { const nm = head.find((t) => t.t === 'str'); a.path.push(`:${nm ? nm.v : 'param'}`); a.captures.push(nm ? nm.v : 'param'); a.order.push(`capture:${nm ? nm.v : 'param'}`); }
|
|
462
561
|
else if (name === 'QueryParam' || name === 'QueryParam\'' || name === 'QueryParams') { const nm = head.find((t) => t.t === 'str'); a.params.push(nm ? nm.v : '?'); a.order.push(`query:${nm ? nm.v : '?'}`); }
|
|
562
|
+
else if (name === 'Header' || name === 'Header\'') { const nm = head.find((t) => t.t === 'str'); a.order.push(`header:${nm ? nm.v : '?'}`); }
|
|
463
563
|
else if (name === 'ReqBody') a.order.push('body');
|
|
464
564
|
else if (name === 'BasicAuth') { a.auth = { kind: 'basic', tag: 'BasicAuth' }; a.order.push('auth'); }
|
|
465
565
|
else if (name === 'AuthProtect') { const nm = head.find((t) => t.t === 'str'); a.auth = { kind: 'token', tag: nm ? nm.v : 'AuthProtect' }; a.order.push('auth'); if (nm && /admin|role|perm/i.test(nm.v)) a.role = true; }
|
|
@@ -654,11 +754,16 @@ export function analyzeHaskellWeb(files, opts = {}) {
|
|
|
654
754
|
(p.servant.order || []).forEach((kind, i) => {
|
|
655
755
|
const nm = paramNames[i]; if (!nm) return;
|
|
656
756
|
if (kind === 'auth') o.principalVars.push(nm);
|
|
757
|
+
else if (/^header:/.test(kind) && CREDENTIAL_HEADER.test(kind.slice(7))) (o.credentialParams ||= []).push({ name: nm, kind: /cookie/i.test(kind) ? 'cookie' : 'token' });
|
|
657
758
|
else if (/^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm);
|
|
658
759
|
});
|
|
659
760
|
} else if (p.framework === 'servant') {
|
|
660
761
|
o.idVars = [];
|
|
661
|
-
(p.servant.order || []).forEach((kind, i) => {
|
|
762
|
+
(p.servant.order || []).forEach((kind, i) => {
|
|
763
|
+
const nm = paramNames[i]; if (!nm) return;
|
|
764
|
+
if (/^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm);
|
|
765
|
+
else if (/^header:/.test(kind) && CREDENTIAL_HEADER.test(kind.slice(7))) (o.credentialParams ||= []).push({ name: nm, kind: /cookie/i.test(kind) ? 'cookie' : 'token' });
|
|
766
|
+
});
|
|
662
767
|
}
|
|
663
768
|
if (p.framework === 'yesod') {
|
|
664
769
|
o.idVars = paramNames.filter(Boolean);
|