@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.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 +34 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1164 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +17 -2
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Personal data in Nix configuration (X-004 / HS-011): a `config.<module>.<field>` option whose NAME classifies as personal data
|
|
2
|
+
// (PII, PHI, PCI, financial) and that is interpolated into the value of another option. Whatever the value of such an option is
|
|
3
|
+
// rendered into (an /etc file, a unit's environment or script, a daemon's configuration text) ends up in the Nix store, which every
|
|
4
|
+
// local user can read, so each reference is a field-to-store flow in the data flow graph.
|
|
5
|
+
//
|
|
6
|
+
// Honest limits:
|
|
7
|
+
// * a reference is judged by its NAME and position only; whether the option holds real personal data is not known;
|
|
8
|
+
// * a reference wrapped in a one-way or size-only function (`builtins.hashString`, `stringLength`) or only compared (`== ""`) is
|
|
9
|
+
// PROTECTED: no flow, and the protection is recorded;
|
|
10
|
+
// * credential-shaped names are the secret analyzer's (NIX-006), not this module's;
|
|
11
|
+
// * static analysis of the written expression only: nothing is evaluated.
|
|
12
|
+
|
|
13
|
+
import { parseNix } from './nix-parser.js';
|
|
14
|
+
import { classifyDataElementName } from '../lineage/classification.js';
|
|
15
|
+
import { isLanguageExcludedPath } from './discovery.js';
|
|
16
|
+
|
|
17
|
+
export const NIX_PRIVACY_VERSION = 'nix-privacy/1';
|
|
18
|
+
|
|
19
|
+
const PROTECTORS = new Set(['hashString', 'hashFile', 'stringLength', 'length', 'sha256', 'sha512']);
|
|
20
|
+
const COMPARISONS = new Set(['==', '!=', '<', '>', '<=', '>=']);
|
|
21
|
+
const CONFIG_ROOTS = new Set(['config', 'osConfig']);
|
|
22
|
+
const PERSONAL = new Set(['PII', 'PHI', 'PCI', 'FIN', 'GEOLOCATION', 'DEVICE_ID']);
|
|
23
|
+
|
|
24
|
+
const staticName = (seg) => (seg && seg.kind === 'static' ? seg.name : null);
|
|
25
|
+
const keyOf = (path) => (path || []).map(staticName);
|
|
26
|
+
|
|
27
|
+
/** The destination a rendered value lands in, from the attribute path it is assigned to. Always the Nix store; the label says what. */
|
|
28
|
+
export function destinationOf(attr) {
|
|
29
|
+
const p = attr.filter(Boolean);
|
|
30
|
+
const last = p[p.length - 1];
|
|
31
|
+
if (p[0] === 'environment' && p[1] === 'etc') return { label: `/etc/${p[2] || 'file'} (a store file)` };
|
|
32
|
+
if (p[0] === 'systemd') {
|
|
33
|
+
if (last === 'script' || /^(?:preStart|postStart|preStop|postStop|reload)$/.test(last)) return { label: 'a systemd unit script (a store file)' };
|
|
34
|
+
if (p.includes('environment') || last === 'Environment') return { label: 'a systemd unit environment (a store file)' };
|
|
35
|
+
return { label: 'a systemd unit (a store file)' };
|
|
36
|
+
}
|
|
37
|
+
if (p[0] === 'environment' && /^(?:variables|sessionVariables|shellAliases)$/.test(p[1] || '')) return { label: 'system environment variables (a store file)' };
|
|
38
|
+
if (p[0] === 'networking' && p[1] === 'extraHosts') return { label: '/etc/hosts (a store file)' };
|
|
39
|
+
if (p[0] === 'programs' && /^(?:bash|zsh|fish)$/.test(p[1] || '')) return { label: 'shell initialisation (a store file)' };
|
|
40
|
+
return { label: `option ${p.join('.')} (rendered into the Nix store)` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function* children(node) {
|
|
44
|
+
if (!node || typeof node !== 'object') return;
|
|
45
|
+
for (const k of Object.keys(node)) {
|
|
46
|
+
if (k === 'span' || k === 'start' || k === 'end') continue;
|
|
47
|
+
const v = node[k];
|
|
48
|
+
if (Array.isArray(v)) { for (const x of v) if (x && typeof x === 'object') yield x; } else if (v && typeof v === 'object') yield v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const calleeName = (fn) => {
|
|
53
|
+
let f = fn;
|
|
54
|
+
while (f && (f.type === 'app')) f = f.fn;
|
|
55
|
+
if (!f) return null;
|
|
56
|
+
if (f.type === 'ident') return f.name;
|
|
57
|
+
if (f.type === 'select') { const names = keyOf(f.attrpath); return names[names.length - 1] || null; }
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{files: Record<string,string>}} opts
|
|
63
|
+
* @returns {{references: object[], gaps: string[]}} one reference per (file, option, personal field) occurrence
|
|
64
|
+
*/
|
|
65
|
+
export function analyzeNixPersonalData(opts = {}) {
|
|
66
|
+
const references = []; const gaps = [];
|
|
67
|
+
for (const [file, text] of Object.entries(opts.files || {})) {
|
|
68
|
+
if (!/\.nix$/i.test(file) || typeof text !== 'string' || isLanguageExcludedPath(file)) continue;
|
|
69
|
+
let rec;
|
|
70
|
+
try { rec = parseNix(text, { file }); } catch (e) { gaps.push(`${file}: Nix parse failed (${String((e && e.message) || e)})`); continue; }
|
|
71
|
+
if (!rec || !rec.ast) continue;
|
|
72
|
+
const visitValue = (node, attr, line, prot) => {
|
|
73
|
+
if (!node || typeof node !== 'object') return;
|
|
74
|
+
if (node.type === 'select' && node.base && node.base.type === 'ident' && CONFIG_ROOTS.has(node.base.name)) {
|
|
75
|
+
const names = keyOf(node.attrpath);
|
|
76
|
+
if (names.length && names.every(Boolean)) {
|
|
77
|
+
const field = names[names.length - 1];
|
|
78
|
+
const classes = classifyDataElementName(field).classes.filter((c) => PERSONAL.has(c));
|
|
79
|
+
if (classes.length) references.push({ file, line, field, option: `config.${names.join('.')}`, attr, classes, protected: prot });
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (node.type === 'app') {
|
|
84
|
+
const name = calleeName(node);
|
|
85
|
+
const nowProt = prot || (name && PROTECTORS.has(name));
|
|
86
|
+
// the callee chain carries no data; its arguments do
|
|
87
|
+
let f = node; const args = [];
|
|
88
|
+
while (f && f.type === 'app') { args.unshift(f.arg); f = f.fn; }
|
|
89
|
+
for (const a of args) visitValue(a, attr, line, nowProt);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (node.type === 'binop' && COMPARISONS.has(node.op)) { visitValue(node.left, attr, line, true); visitValue(node.right, attr, line, true); return; }
|
|
93
|
+
if (node.type === 'if') { visitValue(node.cond, attr, line, true); visitValue(node.then, attr, line, prot); visitValue(node.else, attr, line, prot); return; }
|
|
94
|
+
for (const c of children(node)) visitValue(c, attr, line, prot);
|
|
95
|
+
};
|
|
96
|
+
const visit = (node, attrPath) => {
|
|
97
|
+
if (!node || typeof node !== 'object') return;
|
|
98
|
+
if (node.type === 'attrset') {
|
|
99
|
+
for (const b of node.bindings || []) {
|
|
100
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
101
|
+
const full = [...attrPath, ...keyOf(b.path)];
|
|
102
|
+
const line = (b.span && b.span.startLine) || 1;
|
|
103
|
+
// a value that is an attribute set is walked as nested configuration; anything else is a value being assigned
|
|
104
|
+
if (b.value.type === 'attrset') visit(b.value, full);
|
|
105
|
+
else { visitValue(b.value, full, line, false); for (const c of children(b.value)) visit(c, full); }
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
for (const c of children(node)) visit(c, attrPath);
|
|
110
|
+
};
|
|
111
|
+
visit(rec.ast, []);
|
|
112
|
+
}
|
|
113
|
+
return { references, gaps };
|
|
114
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// Patch-aware Nix vulnerability matching and reachability (NIX-009).
|
|
2
|
+
//
|
|
3
|
+
// Input is a resolved closure (nix-closure.js), pinned advisory records, optional nixpkgs metadata
|
|
4
|
+
// (`knownVulnerabilities`, license, identifiers) and optional overlay/patch evidence from the Nix source.
|
|
5
|
+
// The outcome for each component is one of:
|
|
6
|
+
//
|
|
7
|
+
// affected the upstream version is inside an affected range and nothing excuses it
|
|
8
|
+
// fixed the version/revision is at or past the fix (or the overlay moves it there)
|
|
9
|
+
// backported-verified in range, BUT a patch whose content hash is in the advisory's fix-patch list is applied
|
|
10
|
+
// possibly-affected in range with an UNVERIFIED patch claim (a CVE-named patch file proves nothing),
|
|
11
|
+
// or only a range overlap, or an ambiguous identity with a plausible match
|
|
12
|
+
// candidate the upstream identity is ambiguous or name-only: matches are leads, never verdicts
|
|
13
|
+
// unknown no usable version/identity/feed: this is NEVER reported as "not affected"
|
|
14
|
+
// not-affected mapped, version outside every affected range
|
|
15
|
+
//
|
|
16
|
+
// A nixpkgs commit, a store hash or a derivation hash is never an upstream software version and is never
|
|
17
|
+
// put in a query. Wrapped Haskell packages reuse the Hackage matcher (PVP ordering). Inclusion in the
|
|
18
|
+
// runtime closure and Haskell import/API reachability are separate evidence tiers: a store path or a build
|
|
19
|
+
// input can establish the first and never the second.
|
|
20
|
+
|
|
21
|
+
import { AdvisoryDb, matchComponent as matchHackage, reachability as hackageReachability, licensePolicy, hackagePurl, normalizeAdvisory } from './haskell-sca.js';
|
|
22
|
+
import { parseVersion as parsePvp } from './haskell-manifests.js';
|
|
23
|
+
import { parseNix } from './nix-parser.js';
|
|
24
|
+
|
|
25
|
+
export const NIX_SCA_VERSION = 'nix-sca/1';
|
|
26
|
+
const HEX40 = /^[0-9a-f]{40}$/i;
|
|
27
|
+
const STORE_HASH = /^[0-9a-df-np-sv-z]{32}$/;
|
|
28
|
+
const UPSTREAM_NOISE = /^(?:git|unstable|master|main|HEAD|latest|unstable-\d{4}-\d{2}-\d{2})$/i;
|
|
29
|
+
|
|
30
|
+
/** A nixpkgs revision, a store-path hash or a date stamp is not an upstream version. */
|
|
31
|
+
export function isNotAnUpstreamVersion(v) {
|
|
32
|
+
if (typeof v !== 'string' || !v) return 'no version';
|
|
33
|
+
if (HEX40.test(v)) return 'a 40-hex string is a git revision (for example a nixpkgs commit), not a software version';
|
|
34
|
+
if (STORE_HASH.test(v)) return 'a 32-character base32 string is a store-path hash, not a software version';
|
|
35
|
+
if (/^[0-9a-f]{7,12}$/i.test(v) && /[a-f]/i.test(v)) return 'a short hexadecimal string is a revision, not a software version';
|
|
36
|
+
if (UPSTREAM_NOISE.test(v)) return `"${v}" is a moving ref, not a software version`;
|
|
37
|
+
if (!/\d/.test(v)) return `"${v}" has no numeric component`;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── generic version ordering for upstream software ───────────────────────────
|
|
42
|
+
const PRE = /^(?:alpha|beta|rc|pre|dev|snapshot|a|b)$/i;
|
|
43
|
+
function tokens(v) { return String(v).toLowerCase().replace(/^v(?=\d)/, '').split(/[^a-z0-9]+/).filter(Boolean).flatMap((t) => t.match(/\d+|[a-z]+/g) || []); }
|
|
44
|
+
export function compareUpstream(a, b) {
|
|
45
|
+
const x = tokens(a), y = tokens(b);
|
|
46
|
+
const n = Math.max(x.length, y.length);
|
|
47
|
+
for (let i = 0; i < n; i++) {
|
|
48
|
+
const p = x[i], q = y[i];
|
|
49
|
+
if (p === undefined) return PRE.test(q) ? 1 : (/^\d+$/.test(q) && Number(q) === 0 ? 0 : -1);
|
|
50
|
+
if (q === undefined) return PRE.test(p) ? -1 : (/^\d+$/.test(p) && Number(p) === 0 ? 0 : 1);
|
|
51
|
+
const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
|
|
52
|
+
if (pn && qn) { const d = Number(p) - Number(q); if (d) return d < 0 ? -1 : 1; continue; }
|
|
53
|
+
if (pn !== qn) { if (!pn && PRE.test(p)) return -1; if (!qn && PRE.test(q)) return 1; return pn ? 1 : -1; }
|
|
54
|
+
if (p !== q) return p < q ? -1 : 1;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── advisories ───────────────────────────────────────────────────────────────
|
|
60
|
+
const sha = (s) => String(s || '').toLowerCase().replace(/^sha256[-:]/, '');
|
|
61
|
+
function eventsToRanges(events) {
|
|
62
|
+
const out = []; let lo = null;
|
|
63
|
+
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
|
+
}
|
|
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 });
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
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));
|
|
72
|
+
|
|
73
|
+
/** Normalize a non-Hackage OSV-style record into what Nix matching needs. */
|
|
74
|
+
export function normalizeGenericAdvisory(rec) {
|
|
75
|
+
if (!rec || typeof rec.id !== 'string') return null;
|
|
76
|
+
const aliases = [...new Set((rec.aliases || []).filter((x) => typeof x === 'string'))];
|
|
77
|
+
const affected = [];
|
|
78
|
+
for (const a of rec.affected || []) {
|
|
79
|
+
if (!a || !a.package) continue;
|
|
80
|
+
const ranges = (a.ranges || []).filter((r) => r.type !== 'GIT').flatMap((r) => eventsToRanges(r.events));
|
|
81
|
+
const git = (a.ranges || []).filter((r) => r.type === 'GIT').map((r) => ({ repo: r.repo || null, events: r.events || [] }));
|
|
82
|
+
affected.push({
|
|
83
|
+
ecosystem: a.package.ecosystem || null, name: a.package.name || null, purl: a.package.purl || null, cpe: a.package.cpe || null,
|
|
84
|
+
ranges, versions: Array.isArray(a.versions) ? a.versions : [], git,
|
|
85
|
+
fixedIn: [...new Set((a.ranges || []).flatMap((r) => (r.events || []).filter((e) => 'fixed' in e).map((e) => e.fixed)))],
|
|
86
|
+
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
|
+
});
|
|
88
|
+
}
|
|
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 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
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;
|
|
95
|
+
this.records = records.map(normalizeGenericAdvisory).filter(Boolean);
|
|
96
|
+
this.hackage = hackage; // an AdvisoryDb (HS-009) for wrapped Haskell packages
|
|
97
|
+
const gen = generatedAt ? Date.parse(generatedAt) : NaN;
|
|
98
|
+
this.ageDays = Number.isFinite(gen) ? (now - gen) / 86400000 : null;
|
|
99
|
+
this.stale = this.ageDays === null ? true : this.ageDays > maxAgeDays;
|
|
100
|
+
this.feed = { source, generatedAt, status: this.stale ? 'stale-cache' : 'current', records: this.records.length };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── upstream identity ────────────────────────────────────────────────────────
|
|
105
|
+
const HASKELL_ENV_HINTS = ['libraryHaskellDepends', 'setupHaskellDepends', 'executableHaskellDepends', 'isLibrary', 'isExecutable', 'enableSeparateDataOutput', 'compilerName', 'enableLibraryProfiling'];
|
|
106
|
+
function purlFromUrl(u) {
|
|
107
|
+
const m = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:\/|$)/.exec(u || '');
|
|
108
|
+
if (m) return { purl: `pkg:github/${m[1].toLowerCase()}/${m[2].toLowerCase()}`, host: 'github' };
|
|
109
|
+
const g = /^https?:\/\/gitlab\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:\/|$)/.exec(u || '');
|
|
110
|
+
if (g) return { purl: `pkg:gitlab/${g[1].toLowerCase()}/${g[2].toLowerCase()}`, host: 'gitlab' };
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The identity a component can be matched under. Never invents authority: `explicit` needs the package
|
|
116
|
+
* metadata to say so, `src-derived` needs a recognised source host, anything else is `name-only`.
|
|
117
|
+
*/
|
|
118
|
+
export function upstreamIdentity(node, drvEnv, meta, overlay) {
|
|
119
|
+
const cands = [];
|
|
120
|
+
const version = (overlay && overlay.version) || node.version;
|
|
121
|
+
const vguard = isNotAnUpstreamVersion(version);
|
|
122
|
+
const base = { pname: node.pname, version: vguard ? null : version, versionRejected: vguard || null, versionSource: overlay && overlay.version ? 'overlay' : node.versionAuthority };
|
|
123
|
+
const isHs = drvEnv && HASKELL_ENV_HINTS.some((k) => k in drvEnv);
|
|
124
|
+
if (isHs && node.pname) cands.push({ ecosystem: 'Hackage', name: node.pname, purl: hackagePurl(node.pname, base.version), authority: 'explicit', basis: 'Haskell build attributes in the derivation' });
|
|
125
|
+
const ids = meta && meta.identifiers ? meta.identifiers : null;
|
|
126
|
+
if (ids && typeof ids.purl === 'string') cands.push({ purl: ids.purl, authority: 'explicit', basis: 'meta.identifiers.purl' });
|
|
127
|
+
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);
|
|
129
|
+
const uniqueCpe = [...new Set(cpeStrings)];
|
|
130
|
+
if (uniqueCpe.length === 1 && ids && ids.cpe && !ids.possibleCPEs) cands.push({ cpe: uniqueCpe[0], authority: 'explicit', basis: 'meta.identifiers.cpe' });
|
|
131
|
+
else for (const c of uniqueCpe) cands.push({ cpe: c, authority: 'candidate', basis: uniqueCpe.length > 1 ? 'one of several possible CPEs' : 'possible CPE' });
|
|
132
|
+
for (const u of (node.upstream && node.upstream.urls) || []) { const p = purlFromUrl(u); if (p) cands.push({ purl: p.purl, authority: 'src-derived', basis: `source URL host ${p.host}` }); }
|
|
133
|
+
if (meta && meta.homepage) { const p = purlFromUrl(meta.homepage); if (p) cands.push({ purl: p.purl, authority: 'src-derived', basis: 'meta.homepage' }); }
|
|
134
|
+
if (!cands.some((c) => c.authority === 'explicit' || c.authority === 'src-derived') && node.pname) cands.push({ name: node.pname, authority: 'name-only', basis: 'store/derivation name only' });
|
|
135
|
+
const uniq = []; const seen = new Set();
|
|
136
|
+
for (const c of cands) { const k = JSON.stringify([c.ecosystem, c.name, c.purl, c.cpe, c.authority]); if (!seen.has(k)) { seen.add(k); uniq.push(c); } }
|
|
137
|
+
const strong = uniq.filter((c) => c.authority === 'explicit' || c.authority === 'src-derived');
|
|
138
|
+
const distinctTargets = new Set(strong.map((c) => c.purl || c.cpe || `${c.ecosystem}:${c.name}`));
|
|
139
|
+
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
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── overlays and patches ─────────────────────────────────────────────────────
|
|
143
|
+
const segName = (s) => (s && s.kind === 'static' ? s.name : null);
|
|
144
|
+
const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
|
|
145
|
+
|
|
146
|
+
/** pname -> {version?, patches:[{name,source,sha256?,url?}], file, line} from overlay `overrideAttrs` calls. */
|
|
147
|
+
export function overlayEvidence(files) {
|
|
148
|
+
const out = {};
|
|
149
|
+
for (const [file, text] of Object.entries(files || {})) {
|
|
150
|
+
if (!/\.nix$/i.test(file) || typeof text !== 'string') continue;
|
|
151
|
+
const parse = parseNix(text, { file });
|
|
152
|
+
if (!parse.ast) continue;
|
|
153
|
+
const stack = [parse.ast];
|
|
154
|
+
while (stack.length) {
|
|
155
|
+
const n = stack.pop();
|
|
156
|
+
if (!n || typeof n !== 'object') continue;
|
|
157
|
+
if (n.type === 'attrset') {
|
|
158
|
+
for (const b of n.bindings || []) {
|
|
159
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
160
|
+
const key = (b.path || []).map(segName);
|
|
161
|
+
if (key.length !== 1 || !key[0]) continue;
|
|
162
|
+
const call = unparen(b.value);
|
|
163
|
+
if (!call || call.type !== 'app') continue;
|
|
164
|
+
const fn = unparen(call.fn);
|
|
165
|
+
const last = fn && fn.type === 'select' ? segName(fn.attrpath[fn.attrpath.length - 1]) : null;
|
|
166
|
+
if (last !== 'overrideAttrs') continue;
|
|
167
|
+
const rec = out[key[0]] || (out[key[0]] = { patches: [], file, line: b.span ? b.span.startLine : null, mechanism: 'overlay-overrideAttrs' });
|
|
168
|
+
let body = unparen(call.arg);
|
|
169
|
+
while (body && body.type === 'lambda') body = unparen(body.body);
|
|
170
|
+
if (body && body.type === 'attrset') {
|
|
171
|
+
for (const ob of body.bindings) {
|
|
172
|
+
if (ob.kind !== 'attr') continue;
|
|
173
|
+
const k = (ob.path || []).map(segName).join('.');
|
|
174
|
+
if (k === 'version') { const v = unparen(ob.value); if (v && v.literal !== undefined) rec.version = v.literal; }
|
|
175
|
+
if (k === 'patches') collectPatches(ob.value, rec.patches);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const k of Object.keys(n)) { const c = n[k]; if (c && typeof c === 'object') { if (Array.isArray(c)) stack.push(...c); else stack.push(c); } }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
function collectPatches(node, acc) {
|
|
186
|
+
const n = unparen(node);
|
|
187
|
+
if (!n) return;
|
|
188
|
+
if (n.type === 'list') for (const it of n.items) collectPatches(it, acc);
|
|
189
|
+
else if (n.type === 'binop') { collectPatches(n.left, acc); collectPatches(n.right, acc); }
|
|
190
|
+
else if (n.type === 'path' && n.literal) acc.push({ name: String(n.literal).replace(/^.*\//, ''), source: 'path', sha256: null });
|
|
191
|
+
else if (n.type === 'app') {
|
|
192
|
+
const flat = []; let f = n; while (f && f.type === 'app') { flat.unshift(f.arg); f = unparen(f.fn); }
|
|
193
|
+
const nm = f && f.type === 'select' ? segName(f.attrpath[f.attrpath.length - 1]) : (f && f.type === 'ident' ? f.name : null);
|
|
194
|
+
if (nm === 'fetchpatch' || nm === 'fetchpatch2') {
|
|
195
|
+
const a = unparen(flat[0]); let url = null; let hash = null;
|
|
196
|
+
if (a && a.type === 'attrset') for (const b of a.bindings) { if (b.kind !== 'attr') continue; const k = (b.path || []).map(segName).join('.'); const v = unparen(b.value); if (k === 'url' && v) url = v.literal ?? null; if ((k === 'sha256' || k === 'hash') && v) hash = v.literal ?? null; }
|
|
197
|
+
acc.push({ name: url ? url.replace(/[?#].*$/, '').replace(/^.*\//, '') : 'fetchpatch', source: 'fetchpatch', url, sha256: hash ? sha(hash) : null });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── matching ─────────────────────────────────────────────────────────────────
|
|
203
|
+
const CVE_IN_NAME = /CVE-\d{4}-\d{4,}/i;
|
|
204
|
+
function patchEvidence(patches, advisory, aff) {
|
|
205
|
+
const claims = []; let verified = null;
|
|
206
|
+
const fixHashes = new Set((aff.fixPatches || []).map((p) => p.sha256).filter(Boolean));
|
|
207
|
+
const fixNames = new Set((aff.fixPatches || []).map((p) => p.name).filter(Boolean));
|
|
208
|
+
for (const p of patches || []) {
|
|
209
|
+
const cveInName = (CVE_IN_NAME.exec(p.name || '') || [])[0];
|
|
210
|
+
const namesThis = cveInName && advisory.ids.some((i) => i.toUpperCase() === cveInName.toUpperCase());
|
|
211
|
+
if (p.sha256 && fixHashes.has(sha(p.sha256))) { verified = verified || { patch: p.name, by: 'content hash matches an advisory fix patch' }; continue; }
|
|
212
|
+
if (namesThis || (p.name && fixNames.has(p.name))) claims.push({ patch: p.name, claim: namesThis ? `named after ${cveInName}` : 'name matches an advisory fix patch', verified: false, why: p.sha256 ? 'its content hash is not one the advisory lists' : 'no content hash: a file name proves nothing about what the patch does' });
|
|
213
|
+
}
|
|
214
|
+
return { verified, claims };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function genericMatch(adv, aff, ident, node, patches) {
|
|
218
|
+
const v = ident.version;
|
|
219
|
+
const out = { status: 'unknown', reason: '' };
|
|
220
|
+
if (aff.git.length && node.upstream && node.upstream.rev) {
|
|
221
|
+
const rev = node.upstream.rev.toLowerCase();
|
|
222
|
+
const fixed = aff.git.flatMap((g) => g.events.filter((e) => 'fixed' in e).map((e) => String(e.fixed).toLowerCase()));
|
|
223
|
+
const listed = new Set([...(aff.versions || []).map((x) => String(x).toLowerCase()), ...aff.git.flatMap((g) => g.events.filter((e) => 'introduced' in e || 'last_affected' in e).map((e) => String(e.introduced ?? e.last_affected).toLowerCase()))]);
|
|
224
|
+
if (fixed.includes(rev)) return { status: 'fixed', reason: 'the source revision is the advisory fix commit' };
|
|
225
|
+
if ([...listed].includes(rev)) return { status: 'affected', reason: 'the source revision is an affected commit listed by the advisory' };
|
|
226
|
+
out.reason = 'the source revision is neither the fix nor a listed affected commit; commit ordering is not available, so no verdict';
|
|
227
|
+
if (!v) return out;
|
|
228
|
+
}
|
|
229
|
+
if (!v) return { status: 'unknown', reason: ident.versionRejected || 'no upstream version' };
|
|
230
|
+
let inside = aff.ranges.some((r) => inRange(v, r));
|
|
231
|
+
if (!inside && aff.versions.some((x) => compareUpstream(v, x) === 0)) inside = true;
|
|
232
|
+
if (!aff.ranges.length && !aff.versions.length) return { status: 'unknown', reason: 'the advisory has no version information for this package' };
|
|
233
|
+
if (inside) return { status: 'affected', reason: 'the version is inside an affected range' };
|
|
234
|
+
if ((aff.fixedIn || []).some((f) => compareUpstream(v, f) >= 0)) return { status: 'fixed', reason: `the version is at or past the fix (${aff.fixedIn.join(', ')})` };
|
|
235
|
+
return { status: 'not-affected', reason: 'the version is outside every affected range' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param {{closure: object, data: NixAdvisoryData|null, meta?: Record<string,object>, overlays?: object,
|
|
240
|
+
* haskellUsage?: {imports: object[], callees: Set<string>}, services?: Array<{service:string, packages:string[]}>,
|
|
241
|
+
* symbols?: Record<string,string[]>, kev?: Set<string>|null, epss?: Record<string,number>|null,
|
|
242
|
+
* licensePolicy?: object, drvEnv?: Record<string,object>}} opts
|
|
243
|
+
* meta is keyed by derivation name or pname; drvEnv by derivation path (the env block of the drv show export).
|
|
244
|
+
*/
|
|
245
|
+
export function matchNixVulnerabilities(opts = {}) {
|
|
246
|
+
const { closure, data = null, meta = {}, overlays = {}, haskellUsage = null, services = [], symbols = {}, kev = null, epss = null } = opts;
|
|
247
|
+
const findings = []; const statuses = [];
|
|
248
|
+
const feed = data ? data.feed : { source: null, generatedAt: null, status: 'feed-unavailable', records: 0 };
|
|
249
|
+
const nodes = (closure && closure.nodes) || [];
|
|
250
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
251
|
+
// one subject per derivation: its outputs are one build
|
|
252
|
+
const subjects = new Map();
|
|
253
|
+
const roots = new Set((closure && closure.roots) || []);
|
|
254
|
+
for (const n of nodes) {
|
|
255
|
+
if (n.kind !== 'output' || roots.has(n.id)) continue; // the target itself is not a dependency of itself
|
|
256
|
+
const key = n.deriver || n.id;
|
|
257
|
+
if (!subjects.has(key)) subjects.set(key, { drv: n.deriver || null, outputs: [], node: n });
|
|
258
|
+
const s = subjects.get(key); s.outputs.push(n);
|
|
259
|
+
if (n.outputName === 'out' || (s.node.outputName !== 'out' && n.scopes.includes('runtime'))) s.node = n;
|
|
260
|
+
}
|
|
261
|
+
const summary = { components: 0, matched: 0, mappedNoMatch: 0, unmapped: 0, candidates: 0 };
|
|
262
|
+
for (const s of subjects.values()) {
|
|
263
|
+
const node = s.node;
|
|
264
|
+
const env = (opts.drvEnv && s.drv && opts.drvEnv[s.drv]) || null;
|
|
265
|
+
const m = meta[node.pname] || meta[node.storeName] || null;
|
|
266
|
+
const ov = overlays[node.pname] || null;
|
|
267
|
+
const patches = [...(node.patches || []), ...((ov && ov.patches) || [])];
|
|
268
|
+
const ident = upstreamIdentity(node, env, m, ov);
|
|
269
|
+
summary.components++;
|
|
270
|
+
const nixBuild = { derivation: s.drv, outputs: s.outputs.map((o) => ({ name: o.outputName, path: o.id })), system: node.system, storeHash: node.hash, patches: patches.map((p) => ({ name: p.name, source: p.source || 'derivation', sha256: p.sha256 || null })), overlay: ov ? { file: ov.file, line: ov.line, versionOverride: ov.version || null } : null, scopes: node.scopes };
|
|
271
|
+
const inclusion = node.scopes.includes('runtime') ? 'runtime-closure' : (node.scopes.length ? 'build-only' : 'unknown');
|
|
272
|
+
const svc = services.filter((x) => x.packages.some((p) => p === node.pname || p === node.storeName)).map((x) => x.service);
|
|
273
|
+
const tiers = { inclusion, services: svc, reachability: { import: 'unknown', function: 'unknown', basis: 'a store path or a build input proves inclusion only, never that code is called' } };
|
|
274
|
+
const base = { name: node.pname, version: ident.version, nixBuild, identity: { authority: ident.authority, candidates: ident.candidates, versionSource: ident.versionSource, versionRejected: ident.versionRejected }, tiers, feed };
|
|
275
|
+
const push = (st, extra = {}) => statuses.push({ ...base, status: st.status, reason: st.reason, ...extra });
|
|
276
|
+
|
|
277
|
+
// knownVulnerabilities marked by nixpkgs themselves
|
|
278
|
+
const kvs = (m && Array.isArray(m.knownVulnerabilities) ? m.knownVulnerabilities : []).filter((x) => typeof x === 'string');
|
|
279
|
+
for (const kv of kvs) findings.push(finding(base, { id: `nixpkgs:knownVulnerabilities:${node.pname}`, ids: [kv.match(CVE_IN_NAME) ? kv.match(CVE_IN_NAME)[0] : `nixpkgs-known-vulnerable:${node.pname}`], summary: kv }, 'affected', 'nixpkgs marks this package with knownVulnerabilities', { source: 'nixpkgs-meta', ghc: false }, { kev, epss }));
|
|
280
|
+
if (!data) { push({ status: 'unknown', reason: 'no advisory feed was supplied: absence of a match is not a clean result' }); summary.unmapped++; continue; }
|
|
281
|
+
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
|
+
|
|
283
|
+
let matched = false; let mapped = false;
|
|
284
|
+
// Hackage (wrapped Haskell packages): reuse the PVP matcher
|
|
285
|
+
if (ident.haskell && data.hackage && ident.version && parsePvp(ident.version)) {
|
|
286
|
+
mapped = true;
|
|
287
|
+
for (const { adv, aff } of data.hackage.forPackage(node.pname)) {
|
|
288
|
+
if (adv.withdrawn) continue;
|
|
289
|
+
const r = matchHackage(aff, { name: node.pname, version: ident.version });
|
|
290
|
+
if (r.status === 'not-affected') continue;
|
|
291
|
+
const pe = patchEvidence(patches, { ids: adv.ids }, { fixPatches: [] });
|
|
292
|
+
const st = decide(r, pe, ident);
|
|
293
|
+
matched = true;
|
|
294
|
+
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 }));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// generic records by PURL / CPE; only authoritative identities are matched, candidates are leads
|
|
299
|
+
for (const adv of data.records) {
|
|
300
|
+
for (const aff of adv.affected) {
|
|
301
|
+
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
|
+
if (!target) continue;
|
|
303
|
+
if (aff.ecosystem === 'Hackage') continue; // handled above
|
|
304
|
+
mapped = mapped || target.authority === 'explicit' || target.authority === 'src-derived';
|
|
305
|
+
const gm = genericMatch(adv, aff, ident, node, patches);
|
|
306
|
+
if (gm.status === 'not-affected' && target.authority !== 'candidate' && target.authority !== 'name-only') continue;
|
|
307
|
+
const pe = patchEvidence(patches, adv, aff);
|
|
308
|
+
let st = gm.status === 'affected' ? decide(gm, pe, ident) : gm;
|
|
309
|
+
// identity gate: only an authoritative, unambiguous identity may produce a firm verdict
|
|
310
|
+
if (target.authority === 'candidate' || target.authority === 'name-only' || ident.ambiguous) {
|
|
311
|
+
st = { status: gm.status === 'affected' || gm.status === 'possibly-affected' ? 'candidate' : (gm.status === 'fixed' ? 'candidate' : gm.status === 'not-affected' ? 'unknown' : gm.status), reason: `${gm.reason}; the upstream identity is ${target.authority === 'candidate' ? 'ambiguous (several possible CPEs)' : 'name-only'}, so this is a lead, not a verdict` };
|
|
312
|
+
summary.candidates++;
|
|
313
|
+
}
|
|
314
|
+
matched = true;
|
|
315
|
+
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
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (matched) summary.matched++;
|
|
319
|
+
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
|
+
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
|
+
}
|
|
322
|
+
return { version: NIX_SCA_VERSION, findings, statuses, feed, summary, licenses: licenseReport(nodes.filter((n) => !roots.has(n.id)), meta, opts.licensePolicy) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function decide(r, pe, ident) {
|
|
326
|
+
if (r.status === 'possibly-affected') return { status: 'possibly-affected', reason: r.reason };
|
|
327
|
+
if (pe.verified) return { status: 'backported-verified', reason: `in the affected range, but ${pe.verified.by} (${pe.verified.patch})` };
|
|
328
|
+
if (pe.claims.length) return { status: 'possibly-affected', reason: `in the affected range with an UNVERIFIED patch claim (${pe.claims[0].patch}: ${pe.claims[0].why})` };
|
|
329
|
+
return { status: 'affected', reason: r.reason };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function finding(base, adv, status, reason, extra, { kev, epss }) {
|
|
333
|
+
const cves = adv.cves || (adv.ids || []).filter((x) => /^CVE-/.test(x));
|
|
334
|
+
const kevHit = cves.length ? (kev ? cves.some((c) => kev.has(c)) : 'unknown') : 'not-applicable';
|
|
335
|
+
const ep = cves.map((c) => epss && epss[c]).filter((x) => typeof x === 'number');
|
|
336
|
+
const level = { affected: 'high', 'possibly-affected': 'medium', candidate: 'low', 'backported-verified': 'info', fixed: 'info', unknown: 'low', 'not-affected': 'info' }[status] || 'low';
|
|
337
|
+
return {
|
|
338
|
+
type: 'vulnerable_dep', ecosystem: 'nix', language: 'nix', capability: 'sca', analysisKind: 'application', evidenceKind: 'closure',
|
|
339
|
+
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: 'the advisory carries no severity rating',
|
|
341
|
+
nixBuild: base.nixBuild, identity: base.identity, tiers: base.tiers, feed: base.feed,
|
|
342
|
+
kev: kevHit, epss: ep.length ? Math.max(...ep) : 'unknown',
|
|
343
|
+
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 },
|
|
344
|
+
file: null, line: null, vuln: `Vulnerable package in the Nix closure (${status})`, cwe: 'CWE-1104', parser: 'NIX-SCA', family: 'sca',
|
|
345
|
+
description: `${base.name}${base.version ? ` ${base.version}` : ''} (${adv.id}): ${reason}.`,
|
|
346
|
+
remediation: (extra.fixedIn && extra.fixedIn.length) ? `Update ${base.name} to ${extra.fixedIn.join(' or ')} (override the input or overlay), then regenerate the closure export.` : 'No fixed version is recorded in the advisory data.',
|
|
347
|
+
confidence: status === 'affected' ? 0.85 : status === 'possibly-affected' ? 0.5 : 0.3,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function licenseReport(nodes, meta, policy) {
|
|
352
|
+
const comps = nodes.filter((n) => n.kind === 'output' && (n.outputName === 'out' || !n.outputName)).map((n) => ({ name: n.pname, version: n.version }));
|
|
353
|
+
const md = {};
|
|
354
|
+
for (const c of comps) { const m = meta[c.name]; if (m && m.license) md[c.name] = { license: typeof m.license === 'string' ? m.license : (m.license.spdxId || m.license.shortName || null) }; }
|
|
355
|
+
return licensePolicy(comps, md, policy);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export { AdvisoryDb, normalizeAdvisory };
|