@clear-capabilities/agentic-security-scanner 0.157.2 → 0.159.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 +49 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/4171.index.js +2 -2
- package/dist/{8551.index.js → 7033.index.js} +430 -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 +7 -5
- package/src/dataflow/engine.js +23 -0
- package/src/engine.js +7 -1
- package/src/language/CLAUDE.md +53 -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 +194 -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 +12 -5
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +28 -10
- package/src/language/haskell-security-rules.js +1 -1
- package/src/language/haskell-supply.js +70 -8
- package/src/language/haskell-web.js +143 -10
- 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 +80 -15
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-hardening.js +37 -2
- package/src/language/nixos-module-resolver.js +359 -39
- package/src/language/nixos-option-catalog.js +1 -0
- package/src/language/resolved-pass.js +65 -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,15 +141,19 @@ 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)))];
|
|
147
148
|
|
|
148
149
|
// ── advisory database (pinned snapshot / records) ────────────────────────────
|
|
149
150
|
export class AdvisoryDb {
|
|
150
|
-
constructor({ records = [], source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, integrity = null } = {}) {
|
|
151
|
+
constructor({ records = [], source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, integrity = null, covered = null } = {}) {
|
|
151
152
|
this.source = source;
|
|
153
|
+
// `covered` is {package: ISO time it was last queried}. null means the snapshot makes no per-package claim (a hand-built or
|
|
154
|
+
// operator-pinned snapshot): every package is then treated as covered, as it always was.
|
|
155
|
+
this.covered = covered && typeof covered === 'object' ? covered : null;
|
|
156
|
+
this.now = now;
|
|
152
157
|
this.generatedAt = generatedAt;
|
|
153
158
|
this.integrity = integrity;
|
|
154
159
|
this.problems = [];
|
|
@@ -167,6 +172,13 @@ export class AdvisoryDb {
|
|
|
167
172
|
this.maxAgeDays = maxAgeDays;
|
|
168
173
|
}
|
|
169
174
|
forPackage(name) { return this.byPackage.get(name) || []; }
|
|
175
|
+
/** 'covered' | 'uncovered' | 'stale' for a package; always 'covered' when the snapshot makes no per-package claim. */
|
|
176
|
+
coverage(name) {
|
|
177
|
+
if (!this.covered) return 'covered';
|
|
178
|
+
const t = Date.parse(this.covered[name]);
|
|
179
|
+
if (!Number.isFinite(t)) return 'uncovered';
|
|
180
|
+
return (this.now - t) / 86400000 > this.maxAgeDays ? 'stale' : 'covered';
|
|
181
|
+
}
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
@@ -185,7 +197,7 @@ export function loadAdvisorySnapshot(snapshot, { pinnedSha256 = null, signature
|
|
|
185
197
|
if (!want) return refuse(`record ${rec.id} has no recorded hash`);
|
|
186
198
|
if (sha256(JSON.stringify(rec)) !== want) return refuse(`record ${rec.id} does not match its recorded hash`);
|
|
187
199
|
}
|
|
188
|
-
const body =
|
|
200
|
+
const body = snapshotBody(snapshot);
|
|
189
201
|
const digest = sha256(body);
|
|
190
202
|
if (pinnedSha256 && digest !== pinnedSha256) return refuse('snapshot hash does not match the pinned value');
|
|
191
203
|
let signed = false;
|
|
@@ -197,7 +209,7 @@ export function loadAdvisorySnapshot(snapshot, { pinnedSha256 = null, signature
|
|
|
197
209
|
signed = true;
|
|
198
210
|
} catch (e) { return refuse(`signature check failed: ${e.message}`); }
|
|
199
211
|
}
|
|
200
|
-
const db = new AdvisoryDb({ records: snapshot.records, source: 'snapshot', generatedAt: snapshot.generatedAt, now, maxAgeDays, integrity: { sha256: digest, hashPinned: !!pinnedSha256, signed } });
|
|
212
|
+
const db = new AdvisoryDb({ records: snapshot.records, source: 'snapshot', generatedAt: snapshot.generatedAt, now, maxAgeDays, integrity: { sha256: digest, hashPinned: !!pinnedSha256, signed }, covered: snapshot.covered || null });
|
|
201
213
|
return { ok: true, db, digest };
|
|
202
214
|
}
|
|
203
215
|
|
|
@@ -205,7 +217,8 @@ export function buildSnapshot(records, generatedAt) {
|
|
|
205
217
|
const recordHashes = Object.fromEntries(records.map((r) => [r.id, sha256(JSON.stringify(r))]));
|
|
206
218
|
return { schema: 1, generatedAt, records, recordHashes };
|
|
207
219
|
}
|
|
208
|
-
|
|
220
|
+
// `covered` joins the hashed body only when present, so a snapshot without it keeps the digest it always had.
|
|
221
|
+
export const snapshotBody = (s) => JSON.stringify({ schema: s.schema, generatedAt: s.generatedAt, records: s.records, recordHashes: s.recordHashes, ...(s.covered ? { covered: s.covered } : {}) });
|
|
209
222
|
|
|
210
223
|
// ── matching ─────────────────────────────────────────────────────────────────
|
|
211
224
|
/**
|
|
@@ -219,14 +232,16 @@ export function matchComponent(aff, comp) {
|
|
|
219
232
|
if (inside === null) return { status: 'unknown', reason: `"${comp.version}" is not a Cabal version` };
|
|
220
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' };
|
|
221
234
|
}
|
|
222
|
-
|
|
223
|
-
|
|
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)];
|
|
224
239
|
if (declared === null) return { status: 'unknown', reason: 'the declared range could not be parsed' };
|
|
225
240
|
if (!declared.length) return { status: 'unknown', reason: 'the declared range admits no version' };
|
|
226
241
|
const overlap = intervalsOverlap(declared, aff.intervals);
|
|
227
242
|
if (!overlap) return { status: 'not-affected', reason: 'no version allowed by the declared range is affected' };
|
|
228
243
|
const subset = declared.every((d) => aff.intervals.some((a) => !isEmpty(intersectIntervals(d, a)) && containsInterval(a, d)));
|
|
229
|
-
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' };
|
|
230
245
|
}
|
|
231
246
|
return { status: 'unknown', reason: 'neither a resolved version nor a declared range is known' };
|
|
232
247
|
}
|
|
@@ -251,6 +266,8 @@ export function evaluateComponents(components, db, opts = {}) {
|
|
|
251
266
|
for (const comp of components) {
|
|
252
267
|
const base = { name: comp.name, version: comp.version || null, declaredRange: comp.declaredRange || null, scope: comp.scope || null, target: comp.target || null, purl: hackagePurl(comp.name, comp.version || null) };
|
|
253
268
|
if (!db) { statuses.push({ ...base, status: 'feed-unavailable' }); continue; }
|
|
269
|
+
const cov = db.coverage(comp.name);
|
|
270
|
+
if (cov !== 'covered') { statuses.push({ ...base, status: cov === 'stale' ? 'feed-stale' : 'feed-incomplete', reason: cov === 'stale' ? 'this package was last looked up longer ago than the feed age limit; the absence of advisories is not evidence' : 'the advisory feed never covered this package; the absence of advisories is not evidence' }); continue; }
|
|
254
271
|
const hits = db.forPackage(comp.name);
|
|
255
272
|
if (!hits.length) { statuses.push({ ...base, status: 'no-advisories', feed: feed.status }); continue; }
|
|
256
273
|
let any = false;
|
|
@@ -261,14 +278,15 @@ export function evaluateComponents(components, db, opts = {}) {
|
|
|
261
278
|
statuses.push({ ...base, status: ghc && m.status !== 'not-affected' ? `ghc-component:${m.status}` : m.status, advisory: adv.canonicalId, reason: m.reason, ghcComponent: ghc });
|
|
262
279
|
if (m.status === 'not-affected') continue;
|
|
263
280
|
any = true;
|
|
281
|
+
const sev = adv.severityInfo || { level: null, score: null, basis: NO_RATING_BASIS };
|
|
264
282
|
const kevHit = adv.cveAliases.length ? (opts.kev ? adv.cveAliases.some((c) => opts.kev.has(c)) : 'unknown') : 'not-applicable';
|
|
265
283
|
const epssVals = adv.cveAliases.map((c) => opts.epss && opts.epss[c]).filter((x) => typeof x === 'number');
|
|
266
284
|
findings.push({
|
|
267
285
|
type: 'vulnerable_dep', ecosystem: 'hackage', name: comp.name, version: comp.version || null, declaredRange: comp.declaredRange || null,
|
|
268
286
|
osvId: adv.canonicalId, ids: adv.ids, cveAliases: adv.cveAliases, ghsaAliases: adv.ghsaAliases,
|
|
269
287
|
summary: adv.summary, fixedIn: aff.fixedIn, unfixed: aff.unfixed, references: adv.references.slice(0, 5),
|
|
270
|
-
severity: 'medium', severityBasis:
|
|
271
|
-
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')),
|
|
272
290
|
scope: comp.scope || null, target: comp.target || null, purl: base.purl, ghcComponent: ghc,
|
|
273
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.` }),
|
|
274
292
|
kev: kevHit, epss: epssVals.length ? Math.max(...epssVals) : 'unknown',
|
|
@@ -26,7 +26,7 @@ export const RULES = Object.freeze({
|
|
|
26
26
|
'hs-ecb-mode': { cwe: 'CWE-327', severity: 'high', family: 'weak-crypto', vuln: 'ECB block cipher mode', remediation: 'ECB leaks plaintext structure. Use an AEAD mode (AES-GCM) with a fresh nonce.' },
|
|
27
27
|
'hs-static-iv': { cwe: 'CWE-329', severity: 'high', family: 'weak-crypto', vuln: 'Static IV or nonce', remediation: 'Generate a fresh random IV/nonce per message with `getRandomBytes`; never reuse one with the same key.' },
|
|
28
28
|
'hs-predictable-random': { cwe: 'CWE-338', severity: 'high', family: 'weak-randomness', vuln: 'Predictable random number generator used for a security value', remediation: 'Use `Crypto.Random.getRandomBytes` or `System.Entropy.getEntropy` (OS CSPRNG). `System.Random` is a deterministic PRNG.' },
|
|
29
|
-
'hs-unbounded-request-body': { cwe: 'CWE-
|
|
29
|
+
'hs-unbounded-request-body': { cwe: 'CWE-770', severity: 'medium', family: 'resource-exhaustion', vuln: 'Request body read without a size limit', remediation: 'Apply `requestSizeLimitMiddleware` (wai-extra) or read a bounded number of chunks with `getRequestBodyChunk`.' },
|
|
30
30
|
'hs-xml-entity-substitution': { cwe: 'CWE-611', severity: 'medium', family: 'xxe', vuln: 'XML parser configured to substitute DTD entities', remediation: 'Disable DTD entity substitution (`withSubstDTDEntities no`) for untrusted XML.' },
|
|
31
31
|
'hs-sensitive-logging': { cwe: 'CWE-532', severity: 'high', family: 'sensitive-logging', vuln: 'Sensitive value written to a log or trace', remediation: 'Do not log credentials or secrets. Log an identifier, or redact the value before it reaches the logger.' },
|
|
32
32
|
'hs-insecure-cookie': { cwe: 'CWE-1004', severity: 'medium', family: 'cookie-attributes', vuln: 'Session cookie without HttpOnly or Secure', remediation: 'Set `setCookieHttpOnly = True`, `setCookieSecure = True` and `setCookieSameSite = Just sameSiteStrict` (or Lax) on a session or token cookie.' },
|
|
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { analyzeHaskellManifests } from './haskell-manifests.js';
|
|
8
8
|
import { buildHaskellIR } from './haskell-ir.js';
|
|
9
|
+
import { getLastRefresh, refreshHackageAdvisories, liveFeedEnabled, FEED_ENV } from './haskell-advisory-feed.js';
|
|
9
10
|
import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES } from './haskell-sca.js';
|
|
10
11
|
|
|
11
12
|
const MANIFEST = /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.[a-z]+)?|package\.yaml|stack\.yaml|stack\.yaml\.lock)$/;
|
|
@@ -20,22 +21,56 @@ export function hackageComponents(files) {
|
|
|
20
21
|
const mf = manifestFiles(files);
|
|
21
22
|
if (!mf.length) return { components: [], manifests: null };
|
|
22
23
|
const manifests = analyzeHaskellManifests(mf);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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));
|
|
26
41
|
const seen = new Map();
|
|
27
42
|
for (const d of manifests.dependencies || []) {
|
|
28
|
-
|
|
29
|
-
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
|
|
30
47
|
if (seen.has(key)) continue;
|
|
31
48
|
seen.set(key, {
|
|
32
|
-
ecosystem: 'hackage', name: d.name, version: resolved, declaredRange: d.declaredRange || null,
|
|
33
|
-
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'),
|
|
34
51
|
scope: d.scope || 'runtime', engineScope: SCOPE[d.scope] || 'required', target: d.component || null, componentKind: d.componentKind || null,
|
|
35
52
|
manifest: d.manifest, line: d.line, ghcComponent: GHC_BOOT_PACKAGES.has(d.name),
|
|
36
|
-
versionSource:
|
|
53
|
+
versionSource: lock ? { file: lock.file, line: lock.line || null } : null,
|
|
37
54
|
});
|
|
38
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
|
+
}
|
|
39
74
|
return { components: [...seen.values()], manifests };
|
|
40
75
|
}
|
|
41
76
|
|
|
@@ -61,7 +96,18 @@ function mergeResolved(declared, resolved) {
|
|
|
61
96
|
}
|
|
62
97
|
|
|
63
98
|
/** Advisory data configured for this scan: a hash-pinned snapshot from the environment or the project's state dir. */
|
|
99
|
+
/** The live-feed sentence appended to a "no advisory data" reason: what the refresh did, or how to turn it on. */
|
|
100
|
+
function liveFeedNote(env) {
|
|
101
|
+
const r = getLastRefresh();
|
|
102
|
+
if (r && r.status !== 'disabled') return ` Live feed: ${r.status}, ${r.detail}.`;
|
|
103
|
+
return env[FEED_ENV] === '1' ? '' : ` To fetch advisories from the OSV Hackage feed instead, set ${FEED_ENV}=1 (network, opt-in).`;
|
|
104
|
+
}
|
|
105
|
+
|
|
64
106
|
export function configuredAdvisoryDb(root, env = process.env) {
|
|
107
|
+
const c = _configuredAdvisoryDb(root, env);
|
|
108
|
+
return c.db ? c : { ...c, reason: `${c.reason}${liveFeedNote(env)}` };
|
|
109
|
+
}
|
|
110
|
+
function _configuredAdvisoryDb(root, env = process.env) {
|
|
65
111
|
const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_HACKAGE_ADVISORIES', fileName: 'hackage-advisories.json', root, env });
|
|
66
112
|
if (!sel.path) return { db: null, reason: `no advisory snapshot configured (set AGENTIC_SECURITY_HACKAGE_ADVISORIES, or place hackage-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('hackage-advisories.json') : ''}`, projectLocalIgnored: !!sel.projectLocalIgnored };
|
|
67
113
|
let snap;
|
|
@@ -70,6 +116,20 @@ export function configuredAdvisoryDb(root, env = process.env) {
|
|
|
70
116
|
return r.ok ? { db: r.db, reason: null, source: sel.source } : { db: null, reason: `advisory snapshot refused: ${r.reason}` };
|
|
71
117
|
}
|
|
72
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The async step before a scan's synchronous passes: when the live feed is enabled, make sure the operator snapshot covers every
|
|
121
|
+
* Hackage package this scan will evaluate (declared, frozen, transitive when a plan is present, and Haskell packages inside an imported Nix closure). Does nothing, and costs nothing
|
|
122
|
+
* (no manifest parsing), unless the feed is enabled. Never throws.
|
|
123
|
+
*/
|
|
124
|
+
export async function prefetchHackageFeed(files, { env = process.env, resolvedComponents = [], extraNames = [], ...opts } = {}) {
|
|
125
|
+
if (!liveFeedEnabled(env)) return null;
|
|
126
|
+
try {
|
|
127
|
+
const names = [...hackageComponents(files).components, ...resolvedComponents].map((c) => c.name).concat(extraNames);
|
|
128
|
+
if (!names.length) return null;
|
|
129
|
+
return await refreshHackageAdvisories(names, { env, ...opts });
|
|
130
|
+
} catch (e) { return { status: 'failed', detail: `the feed step failed: ${String((e && e.message) || e).slice(0, 120)}` }; }
|
|
131
|
+
}
|
|
132
|
+
|
|
73
133
|
/** Imports and import-qualified callees across the project's Haskell sources. */
|
|
74
134
|
export function collectUsage(files) {
|
|
75
135
|
const hs = {};
|
|
@@ -101,6 +161,8 @@ export function analyzeHaskellSupply(files, { db = undefined, root = null, env =
|
|
|
101
161
|
out.feed = ev.feed;
|
|
102
162
|
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.` });
|
|
103
163
|
else if (ev.feed.status === 'stale-cache') out.gaps.push({ kind: 'advisory-feed-stale', detail: ev.feed.detail });
|
|
164
|
+
const notCovered = ev.statuses.filter((x) => x.status === 'feed-incomplete' || x.status === 'feed-stale');
|
|
165
|
+
if (db && notCovered.length) out.gaps.push({ kind: 'advisory-feed-incomplete', detail: `${notCovered.length} Hackage package(s) were not covered by the advisory feed (${[...new Set(notCovered.map((x) => x.name))].slice(0, 5).join(', ')}${notCovered.length > 5 ? ', ...' : ''}): their status is unknown, and the absence of findings for them is not a clean result.` });
|
|
104
166
|
const unresolved = components.filter((c) => !c.version);
|
|
105
167
|
if (unresolved.length) out.gaps.push({ kind: 'unresolved-dependency-versions', detail: `${unresolved.length} dependency version(s) are only declared ranges (${unresolved.slice(0, 5).map((c) => c.name).join(', ')}${unresolved.length > 5 ? ', ...' : ''}); generate a Cabal plan or a Stack export to resolve them` });
|
|
106
168
|
|