@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
package/src/language/nix-fix.js
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
99
|
+
if (!parse.ast) return { ok: false, reason: `${file} does not parse` };
|
|
71
100
|
const ir = buildNixIR(parse, { file, source: text });
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
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,
|
|
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,
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
package/src/language/nix-ir.js
CHANGED
|
@@ -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
|
}
|
package/src/language/nix-sca.js
CHANGED
|
@@ -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
|
-
|
|
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 ('
|
|
66
|
-
else if ('
|
|
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,10 +106,17 @@ 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 ────────────────────────────────────────────────────────
|
|
105
|
-
const HASKELL_ENV_HINTS = ['libraryHaskellDepends', 'setupHaskellDepends', 'executableHaskellDepends', 'isLibrary', 'isExecutable', 'enableSeparateDataOutput', 'compilerName', 'enableLibraryProfiling'];
|
|
119
|
+
export const HASKELL_ENV_HINTS = ['libraryHaskellDepends', 'setupHaskellDepends', 'executableHaskellDepends', 'isLibrary', 'isExecutable', 'enableSeparateDataOutput', 'compilerName', 'enableLibraryProfiling'];
|
|
106
120
|
function purlFromUrl(u) {
|
|
107
121
|
const m = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:\/|$)/.exec(u || '');
|
|
108
122
|
if (m) return { purl: `pkg:github/${m[1].toLowerCase()}/${m[2].toLowerCase()}`, host: 'github' };
|
|
@@ -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((
|
|
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; };
|
|
@@ -280,9 +320,14 @@ export function matchNixVulnerabilities(opts = {}) {
|
|
|
280
320
|
if (!data) { push({ status: 'unknown', reason: 'no advisory feed was supplied: absence of a match is not a clean result' }); summary.unmapped++; continue; }
|
|
281
321
|
if (ident.versionRejected && !ident.haskell && !(node.upstream && node.upstream.rev)) { push({ status: 'unknown', reason: `the version cannot be queried: ${ident.versionRejected}` }); summary.unmapped++; continue; }
|
|
282
322
|
|
|
283
|
-
let matched = false; let mapped = false;
|
|
323
|
+
let matched = false; let mapped = false; let hackageGap = null;
|
|
284
324
|
// Hackage (wrapped Haskell packages): reuse the PVP matcher
|
|
285
|
-
if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version)) {
|
|
325
|
+
if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version) && data.hackage.coverage(node.pname) !== 'covered') {
|
|
326
|
+
// A feed that never looked up this package (or looked too long ago) says nothing about it: that is unknown, not "no advisory matches".
|
|
327
|
+
hackageGap = data.hackage.coverage(node.pname) === 'stale'
|
|
328
|
+
? 'the Hackage advisory feed last covered this package longer ago than its age limit: the absence of a match is not a clean result'
|
|
329
|
+
: 'the Hackage advisory feed never covered this package: the absence of a match is not a clean result';
|
|
330
|
+
} else if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version)) {
|
|
286
331
|
mapped = true;
|
|
287
332
|
for (const { adv, aff } of data.hackage.forPackage(node.pname)) {
|
|
288
333
|
if (adv.withdrawn) continue;
|
|
@@ -292,11 +337,12 @@ export function matchNixVulnerabilities(opts = {}) {
|
|
|
292
337
|
const st = decide(r, pe, ident);
|
|
293
338
|
matched = true;
|
|
294
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 }; }
|
|
295
|
-
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 }));
|
|
296
341
|
}
|
|
297
342
|
}
|
|
298
343
|
// generic records by PURL / CPE; only authoritative identities are matched, candidates are leads
|
|
299
344
|
for (const adv of data.records) {
|
|
345
|
+
if (adv.withdrawn) continue;
|
|
300
346
|
for (const aff of adv.affected) {
|
|
301
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));
|
|
302
348
|
if (!target) continue;
|
|
@@ -315,7 +361,22 @@ export function matchNixVulnerabilities(opts = {}) {
|
|
|
315
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 }));
|
|
316
362
|
}
|
|
317
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
|
+
}
|
|
318
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 */ }
|
|
379
|
+
else if (hackageGap) { summary.unmapped++; push({ status: 'unknown', reason: hackageGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
|
|
319
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 }); }
|
|
320
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 }); }
|
|
321
382
|
}
|
|
@@ -334,10 +395,14 @@ function finding(base, adv, status, reason, extra, { kev, epss }) {
|
|
|
334
395
|
const kevHit = cves.length ? (kev ? cves.some((c) => kev.has(c)) : 'unknown') : 'not-applicable';
|
|
335
396
|
const ep = cves.map((c) => epss && epss[c]).filter((x) => typeof x === 'number');
|
|
336
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');
|
|
337
402
|
return {
|
|
338
403
|
type: 'vulnerable_dep', ecosystem: 'nix', language: 'nix', capability: 'sca', analysisKind: 'application', evidenceKind: 'closure',
|
|
339
404
|
name: base.name, version: base.version, osvId: adv.id, ids: adv.ids || [adv.id], cveAliases: cves, summary: adv.summary || '',
|
|
340
|
-
status, matchStatus: status, matchReason: reason, severity: level, severityBasis:
|
|
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 } : {}),
|
|
341
406
|
nixBuild: base.nixBuild, identity: base.identity, tiers: base.tiers, feed: base.feed,
|
|
342
407
|
kev: kevHit, epss: ep.length ? Math.max(...ep) : 'unknown',
|
|
343
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 },
|