@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
@@ -24,7 +24,8 @@ import { analyzeNixScripts } from './nix-script-taint.js';
24
24
  import { analyzeNixSecrets } from './nix-secrets.js';
25
25
  import { analyzeNixBuildTrust } from './nix-build-trust.js';
26
26
  import { analyzeNixosHardening } from './nixos-hardening.js';
27
- import { runFixLifecycle, unifiedDiff, writeWithBackup, undoFix } from './fix-lifecycle.js';
27
+ import { runFixLifecycle, unifiedDiff, writeWithBackup, undoFix, isRootRelativePath } from './fix-lifecycle.js';
28
+ import { resolveNixosConfig } from './nixos-module-resolver.js';
28
29
  import { parseFlakeLock } from './nix-inventory.js';
29
30
 
30
31
  export const NIX_FIX_VERSION = 'nix-fix/1';
@@ -55,30 +56,137 @@ const MANUAL = {
55
56
  };
56
57
  const HASH_MANUAL = new Set(['nix-fetch-missing-hash', 'nix-fetch-fake-hash', 'nix-fetch-floating-rev']);
57
58
 
58
- function planOptionFix(finding, files, fx, source) {
59
- // `optionEvidence` is the option-level evidence a scan carries; `evidence` is that same array on a finding that comes
60
- // straight from the hardening analysis (the scan's own `evidence` field is the list of detectors that agreed).
61
- const evList = Array.isArray(finding.optionEvidence) ? finding.optionEvidence : (Array.isArray(finding.evidence) ? finding.evidence.filter((e) => e && typeof e === 'object') : []);
62
- const evidence = evList[0] || null;
63
- const option = (evidence && evidence.option) || finding.subject;
64
- const winner = evidence && (evidence.sources || []).find((s) => s.role === 'winner');
65
- const file = source ? source.file : (winner ? winner.file : finding.file);
66
- const line = source ? source.line : (winner ? winner.line : finding.line);
67
- const text = files[file];
68
- if (typeof text !== 'string') return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: `the defining file ${file} is not in the supplied tree` };
59
+ // The option each hardening rule judges. Used to pick the right evidence record when a finding carries several.
60
+ const OPTION_OF = {
61
+ 'ssh-root-login': 'services.openssh.settings.PermitRootLogin',
62
+ 'ssh-password-auth': 'services.openssh.settings.PasswordAuthentication',
63
+ 'ssh-empty-passwords': 'services.openssh.settings.PermitEmptyPasswords',
64
+ 'firewall-disabled': 'networking.firewall.enable',
65
+ 'nix-require-sigs-disabled': 'nix.settings.require-sigs',
66
+ 'nix-sandbox-disabled': 'nix.settings.sandbox',
67
+ 'nix-accept-flake-config': 'nix.settings.accept-flake-config',
68
+ };
69
+ // The literal each rule treats as the weakness: the one a conditional branch must hold to be edited.
70
+ const BAD_OF = { 'ssh-root-login': '"yes"', 'ssh-password-auth': 'true', 'ssh-empty-passwords': 'true', 'firewall-disabled': 'false', 'nix-require-sigs-disabled': 'false', 'nix-sandbox-disabled': 'false', 'nix-accept-flake-config': 'true' };
71
+ const literalValue = (to) => (to.startsWith('"') ? to.slice(1, -1) : to === 'true');
72
+
73
+ /** The module a human should put an override line in: the entry configuration. */
74
+ const entryOf = (files, opts) => (opts && opts.entry) || (files['configuration.nix'] !== undefined ? 'configuration.nix' : null);
75
+
76
+ /**
77
+ * The override line to ADD to the entry module when no definition can be edited. A plain assignment conflicts with an
78
+ * equal-priority definition, so the wrapper is chosen from the strongest competing priority: nothing when every
79
+ * definition is weaker than plain, mkForce when plain is the strongest, mkOverride just below anything stronger.
80
+ */
81
+ function overrideSuggestion(option, fx, sources, files, opts) {
82
+ const prios = sources.map((s) => s.priority).filter((n) => Number.isFinite(n));
83
+ const strongest = prios.length ? Math.min(...prios) : 100;
84
+ const base = { file: entryOf(files, opts), option, automatic: false };
85
+ if (strongest > 100) return { ...base, line: `${option} = ${fx.to};`, priority: 100, note: 'every existing definition is weaker than a plain assignment, so no override wrapper is needed.' };
86
+ if (strongest > 50) return { ...base, line: `${option} = lib.mkForce ${fx.to};`, priority: 50, note: 'a plain assignment would conflict with the existing definition at the same priority, so mkForce is required. `lib` must be among the module arguments.' };
87
+ if (strongest > 1) return { ...base, line: `${option} = lib.mkOverride ${strongest - 1} ${fx.to};`, priority: strongest - 1, note: `an existing definition already uses priority ${strongest}, so the override must be stronger than it. \`lib\` must be among the module arguments.` };
88
+ return { ...base, line: null, priority: null, note: 'an existing definition uses the strongest possible priority; nothing can override it from another module.' };
89
+ }
90
+
91
+ const refuse = (status, reason, extra = {}) => ({ ok: false, status, tier: status === 'blocked' ? TIERS.blocked : TIERS.guidance, reason, ...extra });
92
+
93
+ /**
94
+ * Edit the single literal that defines `option` at `file`:`line` in `text`. When several bindings share the line (the branches
95
+ * of one `if`), the one holding the weak literal `bad` is preferred. `skip` reports a definition that already holds the safe value.
96
+ */
97
+ function literalEdit(file, text, line, option, fx, bad) {
69
98
  const parse = parseNix(text, { file });
70
- if (!parse.ast) return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: `${file} does not parse` };
99
+ if (!parse.ast) return { ok: false, reason: `${file} does not parse` };
71
100
  const ir = buildNixIR(parse, { file, source: text });
72
- const cands = ir.bindings.filter((b) => b.pathText === option && b.valueSpan);
73
- const hit = cands.find((b) => b.span && Math.abs(b.span.startLine - line) <= 0) || cands.find((b) => b.span && b.span.startLine <= line && b.span.endLine >= line) || (cands.length === 1 ? cands[0] : null);
74
- if (!hit) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: `no literal definition of ${option} was found at ${file}:${line}: it may be set by a function, an import or an attribute-set merge`, proposal: { option, set: fx.to } };
101
+ const re = fx.kind === 'bool' ? /\b(?:true|false)\b/g : /"(?:[^"\\]|\\.)*"/g;
102
+ const cands = ir.bindings.filter((b) => b.pathText === option && b.valueSpan && b.span);
103
+ const onLine = cands.filter((b) => b.span.startLine === line);
104
+ const within = cands.filter((b) => b.span.startLine <= line && b.span.endLine >= line);
105
+ const pool = onLine.length ? onLine : (within.length ? within : (cands.length === 1 ? cands : []));
106
+ const litOf = (b) => { const t = text.slice(b.valueSpan.startOffset, b.valueSpan.endOffset).match(re) || []; return t.length === 1 ? t[0] : null; };
107
+ const hit = (bad && pool.find((b) => litOf(b) === bad)) || pool.find((b) => litOf(b) !== null && litOf(b) !== fx.to) || pool[0] || null;
108
+ if (!hit) return { ok: false, reason: `no literal definition of ${option} was found at ${file}:${line}: it may be set by a function, an import or an attribute-set merge` };
75
109
  const vs = hit.valueSpan;
76
110
  const current = text.slice(vs.startOffset, vs.endOffset);
77
- const re = fx.kind === 'bool' ? /\b(?:true|false)\b/g : /"(?:[^"\\]|\\.)*"/g;
78
111
  const lits = current.match(re) || [];
79
- if (lits.length !== 1) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: `the definition of ${option} is not a single literal (${current.slice(0, 60)}): the edit would have to choose a branch`, proposal: { option, set: fx.to } };
112
+ if (lits.length !== 1) return { ok: false, reason: `the definition of ${option} at ${file}:${hit.span.startLine} is not a single literal (${current.trim().slice(0, 60)}): the edit would have to choose a branch` };
113
+ if (lits[0] === fx.to) return { ok: true, skip: true, line: hit.span.startLine };
80
114
  const next = current.replace(re, fx.to);
81
- return { ok: true, file, before: text, after: splice(text, vs.startOffset, vs.endOffset, next), ruleId: `nix-set-${option}`, label: 'FULL', explanation: `${option} = ${current.trim()} -> ${next.trim()} at ${file}:${hit.span.startLine}, the definition that wins by priority.`, consequences: fx.consequences, tier: TIERS.edit, behavior: { kind: 'option', option, from: current.trim(), to: next.trim() } };
115
+ return { ok: true, after: splice(text, vs.startOffset, vs.endOffset, next), from: current.trim(), to: next.trim(), line: hit.span.startLine, literal: lits[0] };
116
+ }
117
+
118
+ function planOptionFix(finding, files, fx, source, opts = {}) {
119
+ // `optionEvidence` is the option-level evidence a scan carries; `evidence` is that same array on a finding that comes
120
+ // straight from the hardening analysis (the scan's own `evidence` field is the list of detectors that agreed).
121
+ const evList = Array.isArray(finding.optionEvidence) ? finding.optionEvidence : (Array.isArray(finding.evidence) ? finding.evidence.filter((e) => e && typeof e === 'object') : []);
122
+ const evidence = evList.find((e) => e && e.option === OPTION_OF[finding.rule]) || evList[0] || null;
123
+ const option = (evidence && evidence.option) || finding.subject;
124
+ const sources = (evidence && Array.isArray(evidence.sources)) ? evidence.sources : [];
125
+ const bad = BAD_OF[finding.rule] || null;
126
+ const proposal = (extra = {}) => ({ option, set: fx.to, ...extra });
127
+
128
+ // Findings that carry no per-definition evidence keep the single-file behaviour: edit the reported line.
129
+ if (!sources.length) {
130
+ const file = source ? source.file : finding.file;
131
+ const line = source ? source.line : finding.line;
132
+ if (typeof files[file] !== 'string') return refuse('blocked', `the defining file ${file} is not in the supplied tree`);
133
+ const r = literalEdit(file, files[file], line, option, fx, bad);
134
+ if (!r.ok) return refuse(/does not parse/.test(r.reason) ? 'blocked' : 'manual', r.reason, { proposal: proposal() });
135
+ if (r.skip) return refuse('manual', `the definition of ${option} at ${file}:${r.line} already holds ${fx.to}`, { proposal: proposal() });
136
+ return optionPlanResult(option, fx, [{ file, before: files[file], after: r.after, from: r.from, to: r.to, line: r.line }], [], bad);
137
+ }
138
+
139
+ // Everything that contributes to the effective value: the definitions that win, and those that apply only under a
140
+ // condition. Definitions another one overrides do not contribute and are left alone.
141
+ const live = sources.filter((s) => s.role === 'winner' || s.role === 'conditional');
142
+ const shadowed = sources.filter((s) => s.role === 'shadowed');
143
+ const override = () => overrideSuggestion(option, fx, sources, files, opts);
144
+ if (!live.length) return refuse('manual', `no contributing definition of ${option} could be identified`, { proposal: proposal({ override: override() }) });
145
+
146
+ // The caller chose a definition that another one overrides: changing it cannot change the effective value.
147
+ if (source) {
148
+ const chosen = sources.find((s) => s.file === source.file && s.line === source.line);
149
+ if (chosen && chosen.role === 'shadowed') {
150
+ const by = live.map((w) => `${w.file}:${w.line} (${w.priorityLabel || w.priority})`).join(', ');
151
+ return refuse('blocked', `${source.file}:${source.line} is overridden by ${by}: editing it would leave the effective value of ${option} unchanged`, { proposal: proposal({ editInstead: live.map((w) => ({ file: w.file, line: w.line })), override: override() }) });
152
+ }
153
+ }
154
+
155
+ // Every contributing definition must be editable: inside the project, present, a single literal. Several definitions at the
156
+ // winning priority agree today and the module system rejects a mix, so they are all changed or none is. A conditional
157
+ // branch is edited only where it holds the weak literal; the verification then proves every branch is safe.
158
+ const working = { ...files };
159
+ const edits = [];
160
+ for (const w of live) {
161
+ if (!isRootRelativePath(w.file)) return refuse('manual', `the winning definition of ${option} is in ${w.file}, outside the project root, so it is not edited`, { proposal: proposal({ outsideRoot: w.file, override: override() }) });
162
+ if (typeof working[w.file] !== 'string') return refuse('manual', `the winning definition of ${option} is in ${w.file}, which is not in the supplied tree`, { proposal: proposal({ override: override() }) });
163
+ const r = literalEdit(w.file, working[w.file], w.line, option, fx, bad);
164
+ if (!r.ok) return refuse(/does not parse/.test(r.reason) ? 'blocked' : 'manual', r.reason, { proposal: proposal({ override: override() }) });
165
+ if (r.skip || (w.role === 'conditional' && bad && r.literal !== bad)) continue;
166
+ const prior = edits.find((e) => e.file === w.file);
167
+ if (prior) prior.after = r.after; else edits.push({ file: w.file, before: working[w.file], after: r.after, from: r.from, to: r.to, line: r.line });
168
+ working[w.file] = r.after;
169
+ }
170
+ if (!edits.length) return refuse('manual', `no contributing definition of ${option} holds the weak value, so there is nothing to edit`, { proposal: proposal({ override: override() }) });
171
+ return optionPlanResult(option, fx, edits, shadowed, bad);
172
+ }
173
+
174
+ function optionPlanResult(option, fx, edits, shadowed, bad = null) {
175
+ const first = edits[0];
176
+ const where = edits.map((e) => `${e.file}:${e.line}`).join(', ');
177
+ const multi = edits.length > 1;
178
+ const notes = [...fx.consequences];
179
+ if (shadowed.length) notes.push(`Weaker definitions of ${option} remain at ${shadowed.map((s) => `${s.file}:${s.line}`).join(', ')}; they are overridden and unchanged.`);
180
+ if (multi) notes.push(`${edits.length} files are edited together (${edits.map((e) => e.file).join(', ')}) because each holds a definition at the winning priority; undo restores all of them.`);
181
+ return {
182
+ ok: true, file: first.file, before: first.before, after: first.after,
183
+ edits: edits.map((e) => ({ file: e.file, before: e.before, after: e.after })),
184
+ ruleId: `nix-set-${option}`, label: 'FULL',
185
+ explanation: `${option} = ${first.from} -> ${first.to} at ${where}, the definition${multi ? 's' : ''} that win${multi ? '' : 's'} by priority.`,
186
+ consequences: notes, tier: TIERS.edit,
187
+ behavior: { kind: 'option', option, from: first.from, to: first.to, files: edits.map((e) => e.file) },
188
+ expectEffective: { option, value: literalValue(fx.to), ...(bad ? { bad: literalValue(bad) } : {}) },
189
+ };
82
190
  }
83
191
 
84
192
  function planTransportFix(finding, files) {
@@ -170,7 +278,7 @@ export function planNixFix(finding, files, opts = {}) {
170
278
  const r = finding.rule;
171
279
  if (r === 'nix-shell-injection' || r === 'nix-escape-wrong-context') return planShellFix(finding, files);
172
280
  if (r === 'nix-service-env-shell') return planEnvQuoteFix(finding, files);
173
- if (OPTION_FIXES[r]) return planOptionFix(finding, files, OPTION_FIXES[r], opts.source || null);
281
+ if (OPTION_FIXES[r]) return planOptionFix(finding, files, OPTION_FIXES[r], opts.source || null, opts);
174
282
  if (TRANSPORT_RULES.has(r)) return planTransportFix(finding, files);
175
283
  if (MANUAL[r]) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: 'moving a secret out of the store changes where the credential lives and who can read it: it needs a human migration, never an automatic rewrite', proposal: { steps: MANUAL[r], rotate: r !== 'nix-secret-log', manager: 'sops-nix or agenix' } };
176
284
  if (HASH_MANUAL.has(r)) return { ok: false, status: 'manual', tier: TIERS.blocked, reason: 'the correct hash or revision can only be obtained by fetching the source, which a scan never does; a placeholder hash would be a fabricated value', proposal: { steps: ['Run `nix-prefetch-url` / `nix store prefetch-file` (or build once with lib.fakeHash and read the reported hash) from a trusted machine.', 'Pin a full revision, not a branch.', 'Paste the real hash.'], neverDo: 'insert a made-up or all-zero hash' } };
@@ -193,16 +301,46 @@ export async function rescanNix(files) {
193
301
  }
194
302
  const matchKey = (f) => `${f.rule || f.cwe}|${f.subject || f.attrPath || ''}|${f.file}`;
195
303
  const syntaxGate = (plan) => {
196
- const a = parseNix(plan.before, { file: plan.file }); const b = parseNix(plan.after, { file: plan.file });
197
- const na = (a.errors || []).length, nb = (b.errors || []).length;
198
- return { ok: !!b.ast && nb <= na, errorsBefore: na, errorsAfter: nb, detail: nb <= na ? 'no new syntax errors' : `${nb - na} new syntax error(s)` };
304
+ const edits = Array.isArray(plan.edits) && plan.edits.length ? plan.edits : [{ file: plan.file, before: plan.before, after: plan.after }];
305
+ let na = 0; let nb = 0; let ok = true;
306
+ for (const e of edits) {
307
+ const a = parseNix(e.before, { file: e.file }); const b = parseNix(e.after, { file: e.file });
308
+ na += (a.errors || []).length; nb += (b.errors || []).length;
309
+ if (!b.ast || (b.errors || []).length > (a.errors || []).length) ok = false;
310
+ }
311
+ return { ok, errorsBefore: na, errorsAfter: nb, detail: ok ? 'no new syntax errors' : `${nb - na} new syntax error(s)` };
199
312
  };
200
313
 
314
+ /**
315
+ * The effective-configuration gate of an option fix. The rescan only proves a finding stopped being REPORTED, and an edit that
316
+ * leaves two equal-priority definitions disagreeing does that too (the module system then rejects the configuration). So the
317
+ * option is resolved again over the patched tree and must be a decided `set` value equal to the intended one.
318
+ */
319
+ function makeEffectiveCheck(entry) {
320
+ return (plan, patched) => {
321
+ const want = plan.expectEffective;
322
+ if (!want) return { ok: true, ran: false, detail: 'not an option fix' };
323
+ const multi = Array.isArray(plan.edits) && plan.edits.length > 1;
324
+ if (!entry) return multi ? { ok: false, ran: false, detail: `no entry configuration was found to resolve ${want.option} over the patched tree, and ${plan.edits.length} files were edited` } : { ok: true, ran: false, detail: 'no entry configuration to resolve; the single definition was edited in place' };
325
+ const nixOnly = Object.fromEntries(Object.entries(patched).filter(([p, t]) => /\.nix$/.test(p) && typeof t === 'string'));
326
+ let res;
327
+ try { res = resolveNixosConfig({ entry, files: nixOnly }).lookup(want.option); } catch (e) { return { ok: false, ran: true, detail: `the patched configuration could not be resolved (${e.message})` }; }
328
+ // A value that depends on a condition has no single answer; the fix holds when NO branch can still produce the weak value.
329
+ if (res && res.status === 'conditional' && Array.isArray(res.possibleValues) && res.possibleValues.length && want.bad !== undefined) {
330
+ if (res.possibleValues.includes(want.bad)) return { ok: false, ran: true, option: want.option, detail: `${want.option} can still be ${JSON.stringify(want.bad)} under some condition after the patch` };
331
+ return { ok: true, ran: true, option: want.option, possibleValues: res.possibleValues, detail: `${want.option} is conditional and no branch can produce ${JSON.stringify(want.bad)} (possible: ${res.possibleValues.map((v) => JSON.stringify(v)).join(', ')})` };
332
+ }
333
+ if (!res || res.status !== 'set') return { ok: false, ran: true, option: want.option, status: res && res.status, detail: `${want.option} is "${res && res.status}" after the patch${res && res.reason ? ` (${res.reason})` : ''}, not a decided value` };
334
+ if (res.value !== want.value) return { ok: false, ran: true, option: want.option, detail: `${want.option} is ${JSON.stringify(res.value)} after the patch, not ${JSON.stringify(want.value)}` };
335
+ return { ok: true, ran: true, option: want.option, value: res.value, detail: `${want.option} resolves to ${JSON.stringify(res.value)} over the patched tree` };
336
+ };
337
+ }
338
+
201
339
  /** Validate (and optionally apply) a fix through the shared lifecycle. */
202
340
  export async function validateNixFix(finding, o) {
203
341
  const plan = planNixFix(finding, o.files, o);
204
342
  if (!plan.ok) return { status: plan.status || 'unsupported', applied: false, tier: plan.tier, reason: plan.reason, proposal: plan.proposal || null, ...plan };
205
- const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
343
+ const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, effectiveCheck: makeEffectiveCheck(entryOf(o.files, o)), writeFile: o.writeFile, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
206
344
  return { ...res, consequences: plan.consequences || [], tier: res.status === 'blocked' ? TIERS.blocked : plan.tier, explanation: plan.explanation };
207
345
  }
208
346
 
@@ -165,21 +165,23 @@ class IrBuilder {
165
165
  if (depth > this.b.maxIrDepth) { this.budget('maxIrDepth'); return []; }
166
166
  switch (n.type) {
167
167
  case 'attrset': return this.flattenAttrs(n, ctx, depth);
168
+ // `evalWrap` remembers the OUTERMOST let/with that wraps a leaf value (through nothing but let, with, assert and parentheses), so
169
+ // the module resolver can evaluate the whole wrapper with correct scoping instead of the bare body. Any other combinator clears it.
168
170
  case 'let': {
169
171
  const out = this.recordScoped(n, 'let', ctx);
170
- return out.concat(this.flatten(n.body, ctx, depth + 1));
172
+ return out.concat(this.flatten(n.body, { ...ctx, evalWrap: ctx.evalWrap || { span: n.span, withs: ctx.withs } }, depth + 1));
171
173
  }
172
- case 'with': return this.flatten(n.body, { ...ctx, withs: [...ctx.withs, this.snippet(n.env)] }, depth + 1);
173
- case 'assert': return this.flatten(n.body, ctx, depth + 1);
174
+ case 'with': return this.flatten(n.body, { ...ctx, withs: [...ctx.withs, this.snippet(n.env)], evalWrap: ctx.evalWrap || { span: n.span, withs: ctx.withs } }, depth + 1);
175
+ case 'assert': return this.flatten(n.body, { ...ctx, evalWrap: ctx.evalWrap || { span: n.span, withs: ctx.withs } }, depth + 1);
174
176
  case 'if': {
175
177
  const c = { kind: 'if', span: n.cond.span, text: this.snippet(n.cond) };
176
- return this.flatten(n.then, { ...ctx, conditions: [...ctx.conditions, { ...c, branch: 'then' }] }, depth + 1)
177
- .concat(this.flatten(n.else, { ...ctx, conditions: [...ctx.conditions, { ...c, branch: 'else' }] }, depth + 1));
178
+ return this.flatten(n.then, { ...ctx, evalWrap: null, conditions: [...ctx.conditions, { ...c, branch: 'then' }] }, depth + 1)
179
+ .concat(this.flatten(n.else, { ...ctx, evalWrap: null, conditions: [...ctx.conditions, { ...c, branch: 'else' }] }, depth + 1));
178
180
  }
179
181
  case 'binop':
180
182
  if (n.op === '//') {
181
- const left = this.flatten(n.left, ctx, depth + 1);
182
- const right = this.flatten(n.right, ctx, depth + 1);
183
+ const left = this.flatten(n.left, { ...ctx, evalWrap: null }, depth + 1);
184
+ const right = this.flatten(n.right, { ...ctx, evalWrap: null }, depth + 1);
183
185
  const over = new Set();
184
186
  for (const r of right) if (r.path.length > ctx.prefix.length && r.path[ctx.prefix.length] !== null) over.add(r.path[ctx.prefix.length]);
185
187
  const kept = [];
@@ -196,17 +198,17 @@ class IrBuilder {
196
198
  const name = calleeName(fn);
197
199
  if (name === 'mkMerge' && args.length === 1 && unparen(args[0]).type === 'list') {
198
200
  let out = [];
199
- for (const it of unparen(args[0]).items) out = out.concat(this.flatten(it, { ...ctx, merge: 'mkMerge' }, depth + 1));
201
+ for (const it of unparen(args[0]).items) out = out.concat(this.flatten(it, { ...ctx, evalWrap: null, merge: 'mkMerge' }, depth + 1));
200
202
  return out;
201
203
  }
202
204
  if (COND_WRAPPERS.has(name) && args.length === 2) {
203
205
  const cond = { kind: name, span: args[0].span, text: this.snippet(args[0]), branch: 'then' };
204
- return this.flatten(args[1], { ...ctx, conditions: [...ctx.conditions, cond] }, depth + 1);
206
+ return this.flatten(args[1], { ...ctx, evalWrap: null, conditions: [...ctx.conditions, cond] }, depth + 1);
205
207
  }
206
208
  if (PRIORITY_WRAPPERS.has(name) && args.length >= 1) {
207
209
  // mkOverride carries its numeric priority as the first argument; keep it so the module resolver can rank it
208
210
  const priorityArg = name === 'mkOverride' && args.length >= 2 ? this.summarize(args[0]) : null;
209
- return this.flatten(args[args.length - 1], { ...ctx, priority: name, priorityArg }, depth + 1);
211
+ return this.flatten(args[args.length - 1], { ...ctx, evalWrap: null, priority: name, priorityArg }, depth + 1);
210
212
  }
211
213
  // a file that is just `mkDerivation { ... }` / `callPackage ./x.nix { ... }`: the last attrset argument is the configuration
212
214
  if (!ctx.prefix.length && args.length) {
@@ -229,6 +231,8 @@ class IrBuilder {
229
231
  withs: ctx.withs, overridden: false, update: false, callee: ctx.callee || null,
230
232
  });
231
233
  if (rec && ctx.priorityArg) rec.priorityArg = ctx.priorityArg;
234
+ if (rec && ctx.evalWrap) { rec.evalSpan = ctx.evalWrap.span; rec.evalWiths = ctx.evalWrap.withs; }
235
+ if (rec && ctx.inRec) rec.inRec = true;
232
236
  if (rec && n.type === 'lambda') this.noteBoundaryLambda(n, ctx);
233
237
  return rec ? [rec] : [];
234
238
  }
@@ -262,7 +266,7 @@ class IrBuilder {
262
266
  if (dynamic) {
263
267
  for (const s of b.path) if (s.kind !== 'static') this.gap('dynamic-attribute', `attribute name computed at evaluation time (${this.snippet({ start: s.span.startOffset, end: s.span.endOffset }) || 'interpolated'})`, s.span);
264
268
  }
265
- const sub = { ...ctx, prefix: [...ctx.prefix, ...segs], span: b.span, origin: 'attr' };
269
+ const sub = { ...ctx, prefix: [...ctx.prefix, ...segs], span: b.span, origin: 'attr', evalWrap: null, inRec: ctx.inRec || !!n.rec };
266
270
  out = out.concat(this.flatten(b.value, sub, depth + 1));
267
271
  }
268
272
  return out;
@@ -280,7 +284,7 @@ class IrBuilder {
280
284
  continue;
281
285
  }
282
286
  const { path: segs, dynamic } = staticPath(b.path);
283
- const r = this.addBinding({ path: segs, pathText: segs.map((p) => (p === null ? '${...}' : p)).join('.'), value: this.summarize(b.value), span: b.span, valueSpan: b.value.span, origin: 'let', scope, dynamic, conditions: ctx.conditions, priority: null, merge: null, withs: ctx.withs, overridden: false, update: false });
287
+ const r = this.addBinding({ letSpan: n.span, path: segs, pathText: segs.map((p) => (p === null ? '${...}' : p)).join('.'), value: this.summarize(b.value), span: b.span, valueSpan: b.value.span, origin: 'let', scope, dynamic, conditions: ctx.conditions, priority: null, merge: null, withs: ctx.withs, overridden: false, update: false });
284
288
  if (r) out.push(r);
285
289
  if (dynamic) this.gap('dynamic-attribute', 'dynamic attribute name in let binding', b.span);
286
290
  }
@@ -20,7 +20,9 @@
20
20
 
21
21
  import { AdvisoryDb, matchComponent as matchHackage, reachability as hackageReachability, licensePolicy, hackagePurl, normalizeAdvisory } from './haskell-sca.js';
22
22
  import { parseVersion as parsePvp } from './haskell-manifests.js';
23
+ import { advisorySeverity, NO_RATING_BASIS } from './cvss.js';
23
24
  import { parseNix } from './nix-parser.js';
25
+ import { cpeKey } from './nix-advisory-feed.js';
24
26
 
25
27
  export const NIX_SCA_VERSION = 'nix-sca/1';
26
28
  const HEX40 = /^[0-9a-f]{40}$/i;
@@ -59,16 +61,18 @@ export function compareUpstream(a, b) {
59
61
  // ── advisories ───────────────────────────────────────────────────────────────
60
62
  const sha = (s) => String(s || '').toLowerCase().replace(/^sha256[-:]/, '');
61
63
  function eventsToRanges(events) {
62
- const out = []; let lo = null;
64
+ // `introduced_excluding` is not an OSV event: it carries an exclusive start bound (the NVD live feed has them), so 8.0 is not in "after 8.0".
65
+ const out = []; let lo = null; let loExc = false;
63
66
  for (const e of events || []) {
64
- if ('introduced' in e) lo = e.introduced === '0' ? null : e.introduced;
65
- else if ('fixed' in e) { out.push({ lo, hi: e.fixed, hiInc: false }); lo = null; }
66
- else if ('last_affected' in e) { out.push({ lo, hi: e.last_affected, hiInc: true }); lo = null; }
67
+ if ('introduced' in e) { lo = e.introduced === '0' ? null : e.introduced; loExc = false; }
68
+ else if ('introduced_excluding' in e) { lo = e.introduced_excluding; loExc = true; }
69
+ else if ('fixed' in e) { out.push({ lo, loExc, hi: e.fixed, hiInc: false }); lo = null; loExc = false; }
70
+ else if ('last_affected' in e) { out.push({ lo, loExc, hi: e.last_affected, hiInc: true }); lo = null; loExc = false; }
67
71
  }
68
- if (events && events.some((e) => 'introduced' in e) && (lo !== null || !out.length || !events.some((e) => 'fixed' in e || 'last_affected' in e))) out.push({ lo, hi: null, hiInc: false });
72
+ if (events && events.some((e) => 'introduced' in e || 'introduced_excluding' in e) && (lo !== null || !out.length || !events.some((e) => 'fixed' in e || 'last_affected' in e))) out.push({ lo, loExc, hi: null, hiInc: false });
69
73
  return out;
70
74
  }
71
- const inRange = (v, r) => (r.lo === null || compareUpstream(v, r.lo) >= 0) && (r.hi === null || (r.hiInc ? compareUpstream(v, r.hi) <= 0 : compareUpstream(v, r.hi) < 0));
75
+ const inRange = (v, r) => (r.lo === null || (r.loExc ? compareUpstream(v, r.lo) > 0 : compareUpstream(v, r.lo) >= 0)) && (r.hi === null || (r.hiInc ? compareUpstream(v, r.hi) <= 0 : compareUpstream(v, r.hi) < 0));
72
76
 
73
77
  /** Normalize a non-Hackage OSV-style record into what Nix matching needs. */
74
78
  export function normalizeGenericAdvisory(rec) {
@@ -86,12 +90,15 @@ export function normalizeGenericAdvisory(rec) {
86
90
  fixPatches: ((a.database_specific && a.database_specific.fix_patches) || (rec.database_specific && rec.database_specific.fix_patches) || []).map((p) => ({ name: p.name || null, sha256: p.sha256 ? sha(p.sha256) : null })),
87
91
  });
88
92
  }
89
- return { id: rec.id, ids: [rec.id, ...aliases], cves: [rec.id, ...aliases].filter((x) => /^CVE-/.test(x)), summary: rec.summary || '', withdrawn: rec.withdrawn || null, published: rec.published || null, modified: rec.modified || null, references: (rec.references || []).map((r) => r.url).filter(Boolean), affected };
93
+ return { id: rec.id, severityInfo: advisorySeverity(rec), ids: [rec.id, ...aliases], cves: [rec.id, ...aliases].filter((x) => /^CVE-/.test(x)), summary: rec.summary || '', withdrawn: rec.withdrawn || null, published: rec.published || null, modified: rec.modified || null, references: (rec.references || []).map((r) => r.url).filter(Boolean), affected };
90
94
  }
91
95
 
92
96
  export class NixAdvisoryData {
93
- constructor({ records = [], hackage = null, source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30 } = {}) {
94
- this.source = source; this.generatedAt = generatedAt; this.maxAgeDays = maxAgeDays;
97
+ constructor({ records = [], hackage = null, source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, covered = null } = {}) {
98
+ this.source = source; this.generatedAt = generatedAt; this.maxAgeDays = maxAgeDays; this.now = now;
99
+ // `covered` is {'vendor:product': ISO time that CPE identity was last fully read from the live feed}. null means the snapshot makes no
100
+ // per-identity claim (a hand-built or operator-pinned snapshot) and is matched exactly as it always was.
101
+ this.covered = covered && typeof covered === 'object' ? covered : null;
95
102
  this.records = records.map(normalizeGenericAdvisory).filter(Boolean);
96
103
  this.hackage = hackage; // an AdvisoryDb (HS-009) for wrapped Haskell packages
97
104
  const gen = generatedAt ? Date.parse(generatedAt) : NaN;
@@ -99,6 +106,13 @@ export class NixAdvisoryData {
99
106
  this.stale = this.ageDays === null ? true : this.ageDays > maxAgeDays;
100
107
  this.feed = { source, generatedAt, status: this.stale ? 'stale-cache' : 'current', records: this.records.length };
101
108
  }
109
+ /** 'covered' | 'uncovered' | 'stale' for a CPE identity ('vendor:product'); always 'covered' when the snapshot makes no per-identity claim. */
110
+ cpeCoverage(key) {
111
+ if (!this.covered) return 'covered';
112
+ const t = Date.parse(this.covered[key]);
113
+ if (!Number.isFinite(t)) return 'uncovered';
114
+ return (this.now - t) / 86400000 > this.maxAgeDays ? 'stale' : 'covered';
115
+ }
102
116
  }
103
117
 
104
118
  // ── upstream identity ────────────────────────────────────────────────────────
@@ -125,7 +139,7 @@ export function upstreamIdentity(node, drvEnv, meta, overlay) {
125
139
  const ids = meta && meta.identifiers ? meta.identifiers : null;
126
140
  if (ids && typeof ids.purl === 'string') cands.push({ purl: ids.purl, authority: 'explicit', basis: 'meta.identifiers.purl' });
127
141
  const cpes = ids ? [].concat(ids.cpe || [], ids.possibleCPEs || [], ids.v1 && ids.v1.cpeParts ? [ids.v1.cpeParts] : []).filter(Boolean) : [];
128
- const cpeStrings = cpes.map((c) => (typeof c === 'string' ? c : (c.vendor && c.product ? `${c.vendor}:${c.product}` : null))).filter(Boolean);
142
+ const cpeStrings = cpes.map(cpeKey).filter(Boolean); // 'vendor:product' from a full CPE string or a {vendor, product} part; malformed values are dropped
129
143
  const uniqueCpe = [...new Set(cpeStrings)];
130
144
  if (uniqueCpe.length === 1 && ids && ids.cpe && !ids.possibleCPEs) cands.push({ cpe: uniqueCpe[0], authority: 'explicit', basis: 'meta.identifiers.cpe' });
131
145
  else for (const c of uniqueCpe) cands.push({ cpe: c, authority: 'candidate', basis: uniqueCpe.length > 1 ? 'one of several possible CPEs' : 'possible CPE' });
@@ -139,6 +153,32 @@ export function upstreamIdentity(node, drvEnv, meta, overlay) {
139
153
  return { ...base, candidates: uniq, ambiguous: distinctTargets.size > 1 || (strong.length === 0 && uniq.length > 1) || uniq.some((c) => c.authority === 'candidate'), authority: strong.length && distinctTargets.size === 1 && !uniq.some((c) => c.authority === 'candidate') ? strong[0].authority : (uniq[0] ? 'name-only' : 'none'), haskell: !!isHs };
140
154
  }
141
155
 
156
+ /**
157
+ * The upstream identities of every dependency in a closure, in the form the matcher will use, so a pre-scan step can ask a live feed
158
+ * about exactly what the matcher will look up. `cpes` holds 'vendor:product' keys (explicit and candidate); a component with none is
159
+ * listed in `withoutCpe` so the caller can say what a CPE-keyed feed cannot cover.
160
+ */
161
+ export function closureIdentities({ closure, drvEnv = {}, meta = {}, overlays = {} } = {}) {
162
+ const nodes = (closure && closure.nodes) || [];
163
+ const roots = new Set((closure && closure.roots) || []);
164
+ const subjects = new Map();
165
+ for (const n of nodes) {
166
+ if (n.kind !== 'output' || roots.has(n.id)) continue;
167
+ const key = n.deriver || n.id;
168
+ if (!subjects.has(key)) subjects.set(key, { drv: n.deriver || null, node: n });
169
+ const s = subjects.get(key);
170
+ if (n.outputName === 'out' || (s.node.outputName !== 'out' && n.scopes.includes('runtime'))) s.node = n;
171
+ }
172
+ const cpes = new Set(); const withoutCpe = [];
173
+ for (const s of subjects.values()) {
174
+ const node = s.node;
175
+ const ident = upstreamIdentity(node, (s.drv && drvEnv[s.drv]) || null, meta[node.pname] || meta[node.storeName] || null, overlays[node.pname] || null);
176
+ const mine = ident.candidates.filter((c) => c.cpe).map((c) => c.cpe);
177
+ if (mine.length) mine.forEach((k) => cpes.add(k)); else if (node.pname) withoutCpe.push(node.pname);
178
+ }
179
+ return { cpes: [...cpes].sort(), withoutCpe: [...new Set(withoutCpe)].sort() };
180
+ }
181
+
142
182
  // ── overlays and patches ─────────────────────────────────────────────────────
143
183
  const segName = (s) => (s && s.kind === 'static' ? s.name : null);
144
184
  const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
@@ -297,11 +337,12 @@ export function matchNixVulnerabilities(opts = {}) {
297
337
  const st = decide(r, pe, ident);
298
338
  matched = true;
299
339
  if (haskellUsage) { const rr = hackageReachability(node.pname, haskellUsage.imports, haskellUsage.callees, symbols[adv.id] || null); tiers.reachability = { import: rr.import, function: rr.function, basis: rr.reason }; }
300
- findings.push(finding({ ...base, tiers: { ...tiers } }, { id: adv.canonicalId, ids: adv.ids, summary: adv.summary, cves: adv.cveAliases }, st.status, st.reason, { source: data.hackage.source, ecosystem: 'Hackage', fixedIn: aff.fixedIn, patchEvidence: pe }, { kev, epss }));
340
+ findings.push(finding({ ...base, tiers: { ...tiers } }, { id: adv.canonicalId, ids: adv.ids, summary: adv.summary, cves: adv.cveAliases, severityInfo: adv.severityInfo }, st.status, st.reason, { source: data.hackage.source, ecosystem: 'Hackage', fixedIn: aff.fixedIn, patchEvidence: pe }, { kev, epss }));
301
341
  }
302
342
  }
303
343
  // generic records by PURL / CPE; only authoritative identities are matched, candidates are leads
304
344
  for (const adv of data.records) {
345
+ if (adv.withdrawn) continue;
305
346
  for (const aff of adv.affected) {
306
347
  const target = ident.candidates.find((c) => (c.purl && aff.purl && c.purl === aff.purl) || (c.cpe && aff.cpe && c.cpe === aff.cpe) || (c.name && aff.name && !aff.purl && !aff.cpe && c.name === aff.name && c.authority === 'name-only') || (c.ecosystem === 'Hackage' && aff.ecosystem === 'Hackage' && c.name === aff.name));
307
348
  if (!target) continue;
@@ -320,7 +361,21 @@ export function matchNixVulnerabilities(opts = {}) {
320
361
  findings.push(finding({ ...base, tiers: { ...tiers } }, adv, st.status, st.reason, { source: data.source, ecosystem: aff.ecosystem, fixedIn: aff.fixedIn, patchEvidence: pe, identityBasis: target.basis, identityAuthority: target.authority }, { kev, epss }));
321
362
  }
322
363
  }
364
+ // A feed that records coverage per CPE identity can only vouch for the identities it actually read. Without a covered, explicit,
365
+ // unambiguous CPE identity, "no match" says nothing, so it is unknown (never not-affected).
366
+ let covGap = null;
367
+ if (!hackageGap && data.covered) {
368
+ const cpeCands = ident.candidates.filter((c) => c.cpe);
369
+ const firm = !ident.ambiguous ? cpeCands.find((c) => c.authority === 'explicit') : null;
370
+ if (firm) {
371
+ const cv = data.cpeCoverage(firm.cpe);
372
+ if (cv === 'covered') mapped = true;
373
+ else covGap = cv === 'stale' ? `the live advisory feed last read ${firm.cpe} longer ago than its age limit: the absence of a match is not a clean result` : `the live advisory feed never read ${firm.cpe} (it was not queried or the query did not complete): the absence of a match is not a clean result`;
374
+ } else if (!matched && !cpeCands.length) covGap = 'the live advisory feed is keyed by CPE and this component declares no CPE identity (a source URL or purl alone is not something it can look up): the absence of a match is not a clean result';
375
+ }
323
376
  if (matched) summary.matched++;
377
+ if (covGap) { if (!matched) summary.unmapped++; push({ status: 'unknown', reason: covGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
378
+ else if (matched) { /* findings above are the result */ }
324
379
  else if (hackageGap) { summary.unmapped++; push({ status: 'unknown', reason: hackageGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
325
380
  else if (mapped) { summary.mappedNoMatch++; push({ status: 'not-affected', reason: 'mapped to an upstream identity and no advisory in the supplied feed matches this version' }, { identityMapped: true }); }
326
381
  else { summary.unmapped++; push({ status: 'unknown', reason: ident.ambiguous ? 'the upstream identity is ambiguous: no verdict' : 'the upstream identity could not be mapped to anything an advisory is keyed by' }, { identityMapped: false }); }
@@ -340,10 +395,14 @@ function finding(base, adv, status, reason, extra, { kev, epss }) {
340
395
  const kevHit = cves.length ? (kev ? cves.some((c) => kev.has(c)) : 'unknown') : 'not-applicable';
341
396
  const ep = cves.map((c) => epss && epss[c]).filter((x) => typeof x === 'number');
342
397
  const level = { affected: 'high', 'possibly-affected': 'medium', candidate: 'low', 'backported-verified': 'info', fixed: 'info', unknown: 'low', 'not-affected': 'info' }[status] || 'low';
398
+ // The advisory's own rating applies only where the package is judged vulnerable; the other statuses keep their fixed,
399
+ // lower levels (a verified backport must never be raised by the upstream score).
400
+ const sev = adv.severityInfo || { level: null, score: null, basis: NO_RATING_BASIS };
401
+ const rated = !!sev.level && (status === 'affected' || status === 'possibly-affected');
343
402
  return {
344
403
  type: 'vulnerable_dep', ecosystem: 'nix', language: 'nix', capability: 'sca', analysisKind: 'application', evidenceKind: 'closure',
345
404
  name: base.name, version: base.version, osvId: adv.id, ids: adv.ids || [adv.id], cveAliases: cves, summary: adv.summary || '',
346
- status, matchStatus: status, matchReason: reason, severity: level, severityBasis: 'the advisory carries no severity rating',
405
+ status, matchStatus: status, matchReason: reason, severity: rated ? sev.level : level, severityBasis: rated ? sev.basis : sev.level ? `the match status "${status}" fixes this level; the advisory rating (${sev.basis}) is not applied` : sev.basis, ...(rated ? { severityScore: sev.score } : {}),
347
406
  nixBuild: base.nixBuild, identity: base.identity, tiers: base.tiers, feed: base.feed,
348
407
  kev: kevHit, epss: ep.length ? Math.max(...ep) : 'unknown',
349
408
  fixedIn: extra.fixedIn || [], patchEvidence: extra.patchEvidence || { verified: null, claims: [] }, dataSource: { feed: extra.source || base.feed.source, ecosystem: extra.ecosystem || null, identityBasis: extra.identityBasis || null, identityAuthority: extra.identityAuthority || null, feedStatus: base.feed.status },