@clear-capabilities/agentic-security-scanner 0.158.0 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/1919.index.js +5 -2
  3. package/dist/2862.index.js +29 -9
  4. package/dist/3531.index.js +1 -1
  5. package/dist/4171.index.js +4 -4
  6. package/dist/{8551.index.js → 7033.index.js} +415 -22
  7. package/dist/9560.index.js +3 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +6 -4
  11. package/src/engine.js +6 -1
  12. package/src/language/CLAUDE.md +48 -1
  13. package/src/language/assurance.js +21 -4
  14. package/src/language/context.js +2 -0
  15. package/src/language/cvss.js +91 -0
  16. package/src/language/fix-lifecycle.js +114 -21
  17. package/src/language/haskell-adapter.js +4 -3
  18. package/src/language/haskell-advisory-feed.js +15 -0
  19. package/src/language/haskell-boundaries.js +220 -0
  20. package/src/language/haskell-cpp.js +460 -0
  21. package/src/language/haskell-ir.js +6 -3
  22. package/src/language/haskell-models.js +4 -3
  23. package/src/language/haskell-parser.js +122 -38
  24. package/src/language/haskell-sca.js +10 -6
  25. package/src/language/haskell-supply.js +42 -8
  26. package/src/language/haskell-web.js +128 -23
  27. package/src/language/nix-advisory-feed.js +275 -0
  28. package/src/language/nix-fix.js +162 -24
  29. package/src/language/nix-ir.js +16 -12
  30. package/src/language/nix-sca.js +71 -12
  31. package/src/language/nixos-eval-forms.js +162 -0
  32. package/src/language/nixos-module-resolver.js +346 -51
  33. package/src/language/resolved-pass.js +57 -3
  34. package/src/lsp/server.js +5 -2
  35. package/src/mcp/tools.js +3 -1
  36. package/src/posture/fix-history.js +30 -3
  37. package/src/report/index.js +4 -1
  38. package/dist/1310.index.js +0 -3161
  39. package/dist/2376.index.js +0 -3151
  40. package/dist/3526.index.js +0 -1019
  41. package/dist/7310.index.js +0 -520
  42. package/dist/8520.index.js +0 -13
  43. package/dist/9091.index.js +0 -3135
@@ -25,6 +25,8 @@
25
25
 
26
26
  import { createHash } from 'node:crypto';
27
27
  import { parseSyntax, groupDecls, patternVars } from './haskell-syntax.js';
28
+ import { preprocessForSyntax } from './haskell-parser.js';
29
+ import { deriveCppContext } from './haskell-cpp.js';
28
30
  import { isKnownApi, isSourceApi, qualifyAmbiguous } from './haskell-models.js';
29
31
 
30
32
  export const HS_IR_LIMITS = Object.freeze({
@@ -98,8 +100,8 @@ function callerControlledParams(fn, sig, exported) {
98
100
  }
99
101
 
100
102
  // ── module table ─────────────────────────────────────────────────────────────
101
- function buildModuleInfo(file, src) {
102
- const parse = parseSyntax(src);
103
+ function buildModuleInfo(file, src, cpp) {
104
+ const parse = parseSyntax(preprocessForSyntax(src, { file, cpp }));
103
105
  const groups = groupDecls(parse.decls);
104
106
  const name = parse.module && parse.module.name ? parse.module.name : 'Main';
105
107
  const mod = {
@@ -1174,9 +1176,10 @@ export function buildHaskellIR(fileContents) {
1174
1176
  function buildHaskellIRUncached(fileContents) {
1175
1177
  const proj = new Project();
1176
1178
  const files = Object.keys(fileContents || {}).filter((f) => /\.hs$/i.test(f)).sort();
1179
+ const cppCtx = deriveCppContext(fileContents || {});
1177
1180
  for (const file of files) {
1178
1181
  try {
1179
- const mod = buildModuleInfo(file, fileContents[file]);
1182
+ const mod = buildModuleInfo(file, fileContents[file], cppCtx);
1180
1183
  proj.modules.set(mod.name === 'Main' && proj.modules.has('Main') ? `Main@${file}` : mod.name, mod);
1181
1184
  for (const e of mod.parse.errors) proj.diagnostics.push({ file, line: e.line, kind: e.kind, detail: e.detail });
1182
1185
  } catch (err) {
@@ -235,13 +235,14 @@ export const HS_WEB_FRAMEWORKS = Object.freeze({
235
235
  yesod: { modules: ['Yesod', 'Yesod.Core', 'Yesod.Auth'], package: 'yesod', tested: ['1.6'] },
236
236
  });
237
237
  export const HS_WEB_APIS = Object.freeze([
238
- ...E('Web.Scotty', ['get', 'post', 'put', 'delete', 'patch', 'options', 'addroute', 'matchAny', 'middleware', 'scotty', 'scottyApp', 'status', 'finish', 'raise', 'redirect', 'text', 'json', 'html', 'file', 'setHeader', 'header', 'headers', 'param', 'params', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData', 'files', 'rescue', 'liftIO', 'notFound']),
239
- ...E('Web.Scotty.Trans', ['get', 'post', 'put', 'delete', 'patch', 'middleware', 'scottyT', 'status', 'finish', 'raise', 'redirect', 'text', 'json', 'html', 'header', 'param', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData']),
238
+ ...E('Web.Scotty', ['get', 'post', 'put', 'delete', 'patch', 'options', 'addroute', 'matchAny', 'middleware', 'scotty', 'scottyApp', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'file', 'setHeader', 'header', 'headers', 'param', 'params', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData', 'files', 'rescue', 'liftIO', 'notFound']),
239
+ ...E('Web.Scotty.Trans', ['get', 'post', 'put', 'delete', 'patch', 'middleware', 'scottyT', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'header', 'param', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData']),
240
240
  ...E('Network.Wai', ['pathInfo', 'requestMethod', 'requestHeaders', 'responseLBS', 'queryString', 'rawPathInfo']),
241
241
  ...E('Network.Wai.Handler.Warp', ['run', 'runSettings']),
242
242
  ...E('Network.HTTP.Types', ['status200', 'status201', 'status400', 'status401', 'status403', 'status404', 'unauthorized401', 'forbidden403', 'methodGet', 'methodPost', 'methodPut', 'methodDelete', 'hAuthorization', 'hCookie']),
243
243
  ...E('Network.Wai.Middleware.HttpAuth', ['basicAuth', 'extractBasicAuth']),
244
- ...E('Yesod.Core', ['requireAuthId', 'requireAuth', 'maybeAuthId', 'maybeAuth', 'permissionDenied', 'notAuthenticated', 'lookupGetParam', 'lookupPostParam', 'runInputPost', 'runInputGet', 'getYesod', 'redirect']),
244
+ ...E('Yesod.Core', ['requireAuthId', 'requireAuth', 'maybeAuthId', 'maybeAuth', 'permissionDenied', 'notAuthenticated', 'lookupHeader', 'lookupBearerAuth', 'lookupBasicAuth', 'lookupGetParam', 'lookupPostParam', 'runInputPost', 'runInputGet', 'getYesod', 'redirect']),
245
+ ...E('Yesod', ['requireAuthId', 'requireAuth', 'maybeAuthId', 'maybeAuth', 'permissionDenied', 'notAuthenticated', 'lookupHeader', 'lookupBearerAuth', 'lookupBasicAuth']),
245
246
  ...E('Yesod.Auth', ['requireAuthId', 'requireAuth', 'maybeAuthId', 'maybeAuth', 'isAdmin']),
246
247
  ...E('Servant', ['throwError', 'err401', 'err403', 'err404']),
247
248
  ...E('Servant.Server', ['throwError', 'err401', 'err403', 'err404', 'serve']),
@@ -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
- const depthAtLine = new Int32Array(lines.length + 2);
165
- let depth = 0;
166
- let off = 0;
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
- let line = lines[i];
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
- const name = shebang ? 'shebang' : (m ? m[1] : 'hsc');
174
- directives.push({ name, line: i + 1, span: loc.span(off, off + line.length) });
175
- if (name === 'if' || name === 'ifdef' || name === 'ifndef') depth++;
176
- else if (name === 'endif' && depth > 0) depth--;
177
- depthAtLine[i + 1] = depth;
178
- let cur = blank(line);
179
- // A directive continued with a trailing backslash swallows the next line too.
180
- let k = i;
181
- while (/\\\r?$/.test(lines[k]) && k + 1 < lines.length) {
182
- k++;
183
- cur += '\n' + blank(lines[k]);
184
- depthAtLine[k + 1] = depth;
185
- }
186
- if (k > i) {
187
- off += lines[i].length + 1;
188
- for (let q = i + 1; q <= k; q++) off += lines[q].length + 1;
189
- lines.splice(i, k - i + 1, ...cur.split('\n'));
190
- i = k;
191
- continue;
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
- return { code: lines.join('\n'), directives, depthAtLine };
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) => b.kind !== 'cpp' && b.kind !== 'ffi');
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
- res.boundaries.push({ kind: 'cpp', span: cppLines[0].span, detail: `${cppLines.length} preprocessor line(s) removed in place; every conditional branch was parsed`, count: cppLines.length });
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
- res.boundaries.push({ kind: 'th-top-level-splice', span: spanOf(a, b), detail: 'top-level Template Haskell splice; declarations it generates are not visible' });
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
- if (comp.declaredRange) {
235
- const declared = rangeToIntervals(comp.declaredRange);
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: 'the advisory carries no severity rating',
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
- const locked = new Map();
25
- const lockedAt = new Map();
26
- for (const l of manifests.lockedPackages || []) { locked.set(l.name, l.version); if (l.version && l.file) lockedAt.set(l.name, { file: l.file, line: l.line || null }); }
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
- const resolved = locked.get(d.name) || d.exactPin || null;
30
- const key = `${d.name}@${d.scope || 'runtime'}`;
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: locked.has(d.name) ? 'freeze' : (d.exactPin ? 'exact-pin' : 'unresolved'),
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: locked.has(d.name) && lockedAt.has(d.name) ? lockedAt.get(d.name) : null,
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