@clear-capabilities/agentic-security-scanner 0.158.0 → 0.160.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 +39 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/3531.index.js +1 -1
- package/dist/4171.index.js +4 -4
- package/dist/{8551.index.js → 7033.index.js} +415 -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 +11 -6
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +48 -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 +15 -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 +4 -3
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +10 -6
- package/src/language/haskell-supply.js +42 -8
- package/src/language/haskell-web.js +128 -23
- 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 +71 -12
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-module-resolver.js +346 -51
- package/src/language/resolved-pass.js +57 -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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Live advisory feed for the upstream software inside a Nix closure (NIX-009 follow-on).
|
|
2
|
+
//
|
|
3
|
+
// OSV has no nixpkgs ecosystem, so the Hackage approach (ask OSV by ecosystem and package name) does not carry over. The source used
|
|
4
|
+
// here is the NVD CVE API 2.0 (https://services.nvd.nist.gov/rest/json/cves/2.0), queried by CPE vendor:product, because a nixpkgs
|
|
5
|
+
// package declares its upstream identity as a CPE in meta.identifiers and NVD is the one public, machine-readable feed keyed by it.
|
|
6
|
+
// What it can and cannot answer is stated in docs/guides/nix-nixos.md: an identity that has no CPE (a purl or a source URL alone) is
|
|
7
|
+
// not something this feed is keyed by, so it is reported unknown, never clean.
|
|
8
|
+
//
|
|
9
|
+
// Contract (the same as haskell-advisory-feed.js, in the order it matters):
|
|
10
|
+
// * Opt in. Nothing here touches the network unless AGENTIC_SECURITY_NIX_ADVISORIES_LIVE=1, and never when AGENTIC_SECURITY_OFFLINE=1.
|
|
11
|
+
// An operator-pinned snapshot (AGENTIC_SECURITY_NIX_ADVISORIES) is used as given and is never refreshed over. A hand-written
|
|
12
|
+
// nix-advisories.json in the operator configuration directory is never overwritten either.
|
|
13
|
+
// * Coverage is recorded, not assumed. The snapshot lists every vendor:product it fully read and when. One it did not read, or read
|
|
14
|
+
// only in part, is NOT covered and the matcher reports it unknown.
|
|
15
|
+
// * Failure degrades. An unreachable or throttling feed leaves the previous snapshot in place; the scan runs on it with its age stated.
|
|
16
|
+
// * Everything from the wire is untrusted: the identity is validated before it reaches a URL, responses are size-capped, a record
|
|
17
|
+
// that does not name the requested product is dropped, and a cache whose records fail their own hashes is discarded.
|
|
18
|
+
// * Rate limits. NVD allows 5 requests per rolling 30 seconds without an API key and 50 with one (a key is free; set
|
|
19
|
+
// AGENTIC_SECURITY_NVD_API_KEY). Requests are spaced to stay under that, a throttling response stops the run instead of retrying,
|
|
20
|
+
// and the number of requests per scan is capped, so a large closure fills in over several scans rather than hammering the service.
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { operatorConfigDir } from './trusted-inputs.js';
|
|
26
|
+
|
|
27
|
+
export const FEED_ENV = 'AGENTIC_SECURITY_NIX_ADVISORIES_LIVE';
|
|
28
|
+
export const PINNED_ENV = 'AGENTIC_SECURITY_NIX_ADVISORIES';
|
|
29
|
+
export const KEY_ENV = 'AGENTIC_SECURITY_NVD_API_KEY';
|
|
30
|
+
export const SNAPSHOT_FILE = 'nix-advisories.json';
|
|
31
|
+
export const SNAPSHOT_SCHEMA = 'nix-advisories-live/1';
|
|
32
|
+
export const NVD_BASE = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
|
|
33
|
+
|
|
34
|
+
const DEFAULTS = Object.freeze({
|
|
35
|
+
ttlMs: 24 * 3600 * 1000, // a covered identity is not queried again inside this window
|
|
36
|
+
timeoutMs: 20000,
|
|
37
|
+
maxProducts: 60, // identities considered per scan
|
|
38
|
+
maxRequestsKeyless: 20, // about 2 minutes at the keyless spacing
|
|
39
|
+
maxRequestsKeyed: 120,
|
|
40
|
+
intervalKeylessMs: 6500, // 5 per 30 s, with margin
|
|
41
|
+
intervalKeyedMs: 700, // 50 per 30 s, with margin
|
|
42
|
+
pageSize: 2000, // the NVD maximum
|
|
43
|
+
maxPages: 3,
|
|
44
|
+
maxResponseBytes: 32 << 20,
|
|
45
|
+
maxSnapshotRecords: 30000,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const PART = /^[a-z0-9][a-z0-9_.+~-]{0,63}$/; // a CPE vendor or product, in the lowercase form NVD uses; no colon, slash, quote or escape
|
|
49
|
+
const CVE_ID = /^CVE-\d{4}-\d{4,12}$/;
|
|
50
|
+
const VERSION_OK = /^[A-Za-z0-9][A-Za-z0-9._+~-]{0,63}$/;
|
|
51
|
+
const KEY_OK = /^[0-9a-fA-F-]{20,64}$/;
|
|
52
|
+
|
|
53
|
+
/** 'vendor:product' from a full CPE 2.3 string, a {vendor, product} / {cpe} object, or a key already in that form; null if it cannot be made safely. */
|
|
54
|
+
export function cpeKey(x) {
|
|
55
|
+
let v = null; let p = null;
|
|
56
|
+
if (x && typeof x === 'object') {
|
|
57
|
+
if (typeof x.vendor === 'string' && typeof x.product === 'string') { v = x.vendor; p = x.product; }
|
|
58
|
+
else if (typeof x.cpe === 'string') return cpeKey(x.cpe);
|
|
59
|
+
else return null;
|
|
60
|
+
} else if (typeof x === 'string') {
|
|
61
|
+
if (x.startsWith('cpe:2.3:')) { const parts = x.split(':'); if (parts.length < 5) return null; v = parts[3]; p = parts[4]; }
|
|
62
|
+
else { const parts = x.split(':'); if (parts.length !== 2) return null; [v, p] = parts; }
|
|
63
|
+
} else return null;
|
|
64
|
+
v = v.toLowerCase(); p = p.toLowerCase();
|
|
65
|
+
return PART.test(v) && PART.test(p) ? `${v}:${p}` : null;
|
|
66
|
+
}
|
|
67
|
+
export const validCpeKey = (k) => typeof k === 'string' && cpeKey(k) === k;
|
|
68
|
+
|
|
69
|
+
let last = null;
|
|
70
|
+
/** What the most recent refresh in this process did, so a "no feed" reason can say why. */
|
|
71
|
+
export const getLastRefresh = () => last;
|
|
72
|
+
export const _resetLastRefresh = () => { last = null; };
|
|
73
|
+
|
|
74
|
+
export function liveFeedEnabled(env = process.env) {
|
|
75
|
+
return env[FEED_ENV] === '1' && env.AGENTIC_SECURITY_OFFLINE !== '1';
|
|
76
|
+
}
|
|
77
|
+
export function snapshotPath(env = process.env, dir = null) { return join(dir || operatorConfigDir(env), SNAPSHOT_FILE); }
|
|
78
|
+
|
|
79
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate a snapshot written by this module. Returns {ok:true, records, covered, generatedAt} or {ok:false, reason}.
|
|
83
|
+
* `foreign` marks a parseable file that is not ours (a hand-written operator snapshot): not an error, and never overwritten.
|
|
84
|
+
*/
|
|
85
|
+
export function loadLiveSnapshot(raw) {
|
|
86
|
+
if (!raw || typeof raw !== 'object' || raw.schema !== SNAPSHOT_SCHEMA) return { ok: false, foreign: true, reason: 'not a live-feed snapshot' };
|
|
87
|
+
if (!Array.isArray(raw.records) || !raw.recordHashes || typeof raw.recordHashes !== 'object') return { ok: false, reason: 'the snapshot has no record list or no record hashes' };
|
|
88
|
+
for (const rec of raw.records) {
|
|
89
|
+
if (!rec || typeof rec.id !== 'string' || !CVE_ID.test(rec.id)) return { ok: false, reason: 'a record has no valid identifier' };
|
|
90
|
+
if (raw.recordHashes[rec.id] !== sha256(JSON.stringify(rec))) return { ok: false, reason: `record ${rec.id} does not match its recorded hash` };
|
|
91
|
+
}
|
|
92
|
+
const covered = {};
|
|
93
|
+
if (!raw.covered || typeof raw.covered !== 'object' || Array.isArray(raw.covered)) return { ok: false, reason: 'the coverage table is missing or malformed' };
|
|
94
|
+
for (const [k, t] of Object.entries(raw.covered)) { if (!validCpeKey(k) || !Number.isFinite(Date.parse(t))) return { ok: false, reason: 'the coverage table has a malformed entry' }; covered[k] = t; }
|
|
95
|
+
return { ok: true, records: raw.records, covered, generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readCurrent(path) {
|
|
99
|
+
let raw;
|
|
100
|
+
try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { return { records: new Map(), covered: {}, problem: e && e.code === 'ENOENT' ? null : 'the cached snapshot was unreadable and was discarded', foreign: false }; }
|
|
101
|
+
const r = loadLiveSnapshot(raw);
|
|
102
|
+
if (r.foreign) return { records: new Map(), covered: {}, problem: null, foreign: true };
|
|
103
|
+
if (!r.ok) return { records: new Map(), covered: {}, problem: `the cached snapshot was discarded: ${r.reason}`, foreign: false };
|
|
104
|
+
return { records: new Map(r.records.map((x) => [x.id, x])), covered: r.covered, problem: null, foreign: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const splitCpe = (s) => s.split(/(?<!\\):/);
|
|
108
|
+
const isoOrNull = (t) => {
|
|
109
|
+
if (typeof t !== 'string') return null;
|
|
110
|
+
const ms = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(t) ? t : `${t}Z`); // NVD timestamps carry no zone and are UTC
|
|
111
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One NVD CVE object -> an OSV-style record limited to the requested vendor:product, or null when the record does not name it
|
|
116
|
+
* (or is rejected, or carries nothing usable). Only `vulnerable` matches of the requested product are read; operating-system or
|
|
117
|
+
* hardware conditions on a combined configuration are NOT evaluated (see the guide).
|
|
118
|
+
* A start bound that is exclusive is carried as an `introduced_excluding` event, which the Nix matcher understands.
|
|
119
|
+
*/
|
|
120
|
+
export function nvdToOsv(cve, key) {
|
|
121
|
+
if (!cve || typeof cve !== 'object' || typeof cve.id !== 'string' || !CVE_ID.test(cve.id)) return null;
|
|
122
|
+
if (typeof cve.vulnStatus === 'string' && /^rejected$/i.test(cve.vulnStatus)) return null;
|
|
123
|
+
const ranges = []; const versions = [];
|
|
124
|
+
for (const cfg of Array.isArray(cve.configurations) ? cve.configurations : []) {
|
|
125
|
+
for (const node of Array.isArray(cfg && cfg.nodes) ? cfg.nodes : []) {
|
|
126
|
+
if (node && node.negate === true) continue;
|
|
127
|
+
for (const m of Array.isArray(node && node.cpeMatch) ? node.cpeMatch : []) {
|
|
128
|
+
if (!m || m.vulnerable !== true || typeof m.criteria !== 'string' || !m.criteria.startsWith('cpe:2.3:')) continue;
|
|
129
|
+
const parts = splitCpe(m.criteria);
|
|
130
|
+
if (parts.length < 6 || !['a', 'o', '*'].includes(parts[2]) || `${parts[3]}:${parts[4]}` !== key) continue;
|
|
131
|
+
const ver = parts[5]; const upd = parts[6];
|
|
132
|
+
if (ver === '*') {
|
|
133
|
+
const s1 = m.versionStartIncluding; const s2 = m.versionStartExcluding; const e1 = m.versionEndIncluding; const e2 = m.versionEndExcluding;
|
|
134
|
+
if ([s1, s2, e1, e2].some((x) => x !== undefined && (typeof x !== 'string' || !VERSION_OK.test(x)))) continue;
|
|
135
|
+
const events = [];
|
|
136
|
+
if (s1) events.push({ introduced: s1 }); else if (s2) events.push({ introduced_excluding: s2 }); else events.push({ introduced: '0' });
|
|
137
|
+
if (e2) events.push({ fixed: e2 }); else if (e1) events.push({ last_affected: e1 });
|
|
138
|
+
ranges.push({ type: 'ECOSYSTEM', events });
|
|
139
|
+
} else if (ver !== '-' && ver !== '?') {
|
|
140
|
+
const v = upd && upd !== '*' && upd !== '-' ? `${ver}-${upd}` : ver;
|
|
141
|
+
if (VERSION_OK.test(v)) versions.push(v);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!ranges.length && !versions.length) return null;
|
|
147
|
+
const desc = (Array.isArray(cve.descriptions) ? cve.descriptions : []).find((d) => d && d.lang === 'en' && typeof d.value === 'string');
|
|
148
|
+
const refs = (Array.isArray(cve.references) ? cve.references : []).map((r) => r && r.url).filter((u) => typeof u === 'string' && /^https?:\/\//.test(u) && u.length <= 300).slice(0, 5);
|
|
149
|
+
return {
|
|
150
|
+
id: cve.id, aliases: [], summary: desc ? desc.value.slice(0, 400) : '', published: isoOrNull(cve.published), modified: isoOrNull(cve.lastModified),
|
|
151
|
+
affected: [{ package: { ecosystem: 'NVD-CPE', name: key.split(':')[1], cpe: key }, ranges, versions }],
|
|
152
|
+
references: refs.map((url) => ({ type: 'WEB', url })), database_specific: { source: 'nvd-cve-2.0' },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sleepReal = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
157
|
+
|
|
158
|
+
async function getJson(fetchImpl, url, headers, timeoutMs, maxBytes) {
|
|
159
|
+
const resp = await fetchImpl(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
160
|
+
if (!resp) throw Object.assign(new Error('no response'), { http: 0 });
|
|
161
|
+
if (resp.status !== 200) throw Object.assign(new Error(`HTTP ${resp.status}`), { http: resp.status });
|
|
162
|
+
const text = await resp.text();
|
|
163
|
+
if (Buffer.byteLength(text) > maxBytes) throw new Error('response too large');
|
|
164
|
+
return JSON.parse(text);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Refresh the operator's Nix advisory snapshot for the given CPE identities ('vendor:product' keys or anything cpeKey accepts).
|
|
169
|
+
* Never throws. Returns a plain result; the snapshot file is rewritten only when something was read.
|
|
170
|
+
*
|
|
171
|
+
* @param {Array<string|object>} identities
|
|
172
|
+
* @param {{env?: object, fetchImpl?: Function, now?: number, dir?: string, sleep?: Function}} [opts]
|
|
173
|
+
*/
|
|
174
|
+
export async function refreshNixAdvisories(identities, opts = {}) {
|
|
175
|
+
const env = opts.env || process.env;
|
|
176
|
+
const cfg = { ...DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([k]) => k in DEFAULTS)) };
|
|
177
|
+
const now = opts.now ?? Date.now();
|
|
178
|
+
const done = (r) => { last = { ...r, at: new Date(now).toISOString() }; return last; };
|
|
179
|
+
|
|
180
|
+
if (env[PINNED_ENV]) return done({ status: 'pinned-snapshot-in-use', detail: `${PINNED_ENV} names an operator snapshot; it is used as given and is not refreshed` });
|
|
181
|
+
if (env[FEED_ENV] !== '1') return done({ status: 'disabled', detail: `set ${FEED_ENV}=1 to fetch advisories for the closure's upstream software from the NVD` });
|
|
182
|
+
if (env.AGENTIC_SECURITY_OFFLINE === '1') return done({ status: 'offline', detail: 'AGENTIC_SECURITY_OFFLINE=1: no advisory data was fetched' });
|
|
183
|
+
|
|
184
|
+
const all = (identities || []).map(cpeKey);
|
|
185
|
+
const invalid = all.filter((k) => !k).length;
|
|
186
|
+
const wanted = [...new Set(all.filter(Boolean))].sort().slice(0, cfg.maxProducts);
|
|
187
|
+
if (!wanted.length) return done({ status: 'nothing-to-do', detail: invalid ? `${invalid} identity value(s) were not valid CPE vendor:product names and were not queried` : 'the closure declares no CPE identity to look up', invalid });
|
|
188
|
+
|
|
189
|
+
const path = snapshotPath(env, opts.dir);
|
|
190
|
+
const cur = readCurrent(path);
|
|
191
|
+
if (cur.foreign) return done({ status: 'operator-snapshot-in-use', detail: `${path} is an operator-written snapshot; it is used as given and the live feed does not overwrite it` });
|
|
192
|
+
const need = wanted.filter((k) => { const t = Date.parse(cur.covered[k]); return !Number.isFinite(t) || now - t > cfg.ttlMs || t > now + 3600000; });
|
|
193
|
+
if (!need.length) return done({ status: 'current', detail: `${wanted.length} identity(ies) covered within the last ${Math.round(cfg.ttlMs / 3600000)}h; no request made`, identities: wanted.length, requested: 0 });
|
|
194
|
+
|
|
195
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
196
|
+
if (typeof fetchImpl !== 'function') return done({ status: 'failed', detail: 'no fetch implementation is available in this runtime' });
|
|
197
|
+
const sleep = opts.sleep || sleepReal;
|
|
198
|
+
const apiKey = typeof env[KEY_ENV] === 'string' && KEY_OK.test(env[KEY_ENV]) ? env[KEY_ENV] : null;
|
|
199
|
+
const keyNote = env[KEY_ENV] && !apiKey ? ` (${KEY_ENV} is set but is not a valid key; it was ignored)` : '';
|
|
200
|
+
const headers = { 'User-Agent': 'agentic-security-advisory-feed', Accept: 'application/json', ...(apiKey ? { apiKey } : {}) };
|
|
201
|
+
const interval = apiKey ? cfg.intervalKeyedMs : cfg.intervalKeylessMs;
|
|
202
|
+
const maxRequests = apiKey ? cfg.maxRequestsKeyed : cfg.maxRequestsKeyless;
|
|
203
|
+
|
|
204
|
+
let requests = 0; let throttled = false; let dropped = 0;
|
|
205
|
+
const failures = []; const fresh = new Map(); // key -> Map(cveId -> record)
|
|
206
|
+
const request = async (url) => { if (requests > 0) await sleep(interval); requests++; return getJson(fetchImpl, url, headers, cfg.timeoutMs, cfg.maxResponseBytes); };
|
|
207
|
+
|
|
208
|
+
for (const key of need) {
|
|
209
|
+
if (throttled) { failures.push(`${key}: not queried (the service signalled a rate limit)`); continue; }
|
|
210
|
+
if (requests >= maxRequests) { failures.push(`${key}: not queried (per-scan request cap of ${maxRequests} reached; a later scan will cover it)`); continue; }
|
|
211
|
+
const recs = new Map();
|
|
212
|
+
try {
|
|
213
|
+
let start = 0; let total = null; let seen = 0;
|
|
214
|
+
for (let page = 0; page < cfg.maxPages; page++) {
|
|
215
|
+
if (page > 0 && requests >= maxRequests) throw new Error('per-scan request cap reached part-way through this identity');
|
|
216
|
+
const url = `${NVD_BASE}?virtualMatchString=${encodeURIComponent(`cpe:2.3:*:${key}`)}&resultsPerPage=${cfg.pageSize}&startIndex=${start}`;
|
|
217
|
+
const data = await request(url);
|
|
218
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.vulnerabilities) || !Number.isInteger(data.totalResults) || data.totalResults < 0 || data.startIndex !== start) throw new Error('the response is not a CVE 2.0 page for this request');
|
|
219
|
+
if (total === null) { total = data.totalResults; if (total > cfg.pageSize * cfg.maxPages) throw new Error(`${total} CVEs name this product, more than the ${cfg.pageSize * cfg.maxPages} this feed reads; not treated as covered`); }
|
|
220
|
+
else if (data.totalResults !== total) throw new Error('the result set changed while it was being read');
|
|
221
|
+
for (const v of data.vulnerabilities) {
|
|
222
|
+
const rec = nvdToOsv(v && v.cve, key);
|
|
223
|
+
if (rec) recs.set(rec.id, rec); else dropped++;
|
|
224
|
+
}
|
|
225
|
+
seen += data.vulnerabilities.length; start += data.vulnerabilities.length;
|
|
226
|
+
if (seen >= total || !data.vulnerabilities.length) break;
|
|
227
|
+
}
|
|
228
|
+
if (seen < total) throw new Error('the result set was not read to the end');
|
|
229
|
+
fresh.set(key, recs);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
const http = e && e.http;
|
|
232
|
+
if (http === 403 || http === 429 || http === 503) throttled = true;
|
|
233
|
+
failures.push(`${key}: ${String((e && e.message) || e).slice(0, 100)}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const uncovered = need.filter((k) => !fresh.has(k));
|
|
238
|
+
if (!fresh.size) return done({ status: 'failed', detail: `the NVD feed could not be read (${failures.slice(0, 2).join('; ') || 'no response'}); ${cur.records.size ? 'the previous snapshot is used and its age is disclosed' : 'no advisory data is available'}${keyNote}`, identities: wanted.length, requested: need.length, requests, uncovered, failures: failures.slice(0, 10), throttled, cacheProblem: cur.problem });
|
|
239
|
+
|
|
240
|
+
// merge: for each fully read identity, replace what the cache held for it and keep everything else
|
|
241
|
+
const records = new Map([...cur.records].map(([id, r]) => [id, { ...r, affected: [...(r.affected || [])] }]));
|
|
242
|
+
for (const [key, recs] of fresh) {
|
|
243
|
+
for (const [id, r] of [...records]) { r.affected = r.affected.filter((a) => !(a.package && a.package.cpe === key)); if (!r.affected.length) records.delete(id); }
|
|
244
|
+
for (const [id, rec] of recs) {
|
|
245
|
+
const have = records.get(id);
|
|
246
|
+
if (have) have.affected.push(...rec.affected); else records.set(id, rec);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (records.size > cfg.maxSnapshotRecords) return done({ status: 'failed', detail: `the snapshot would exceed ${cfg.maxSnapshotRecords} records; refusing to write it`, identities: wanted.length, requested: need.length, uncovered });
|
|
250
|
+
const covered = { ...cur.covered };
|
|
251
|
+
const iso = new Date(now).toISOString();
|
|
252
|
+
for (const k of fresh.keys()) covered[k] = iso;
|
|
253
|
+
|
|
254
|
+
const sorted = [...records.values()].sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
255
|
+
const snap = {
|
|
256
|
+
schema: SNAPSHOT_SCHEMA, source: 'nvd-cve-2.0', generatedAt: iso, records: sorted,
|
|
257
|
+
recordHashes: Object.fromEntries(sorted.map((r) => [r.id, sha256(JSON.stringify(r))])),
|
|
258
|
+
covered: Object.fromEntries(Object.entries(covered).sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
const dir = opts.dir || operatorConfigDir(env);
|
|
262
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
263
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
264
|
+
writeFileSync(tmp, JSON.stringify(snap), { mode: 0o600 });
|
|
265
|
+
try { chmodSync(tmp, 0o600); } catch { /* best effort on filesystems without modes */ }
|
|
266
|
+
renameSync(tmp, path);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
return done({ status: 'failed', detail: `the snapshot could not be written: ${e.code || e.message}`, identities: wanted.length, requested: need.length, uncovered });
|
|
269
|
+
}
|
|
270
|
+
return done({
|
|
271
|
+
status: uncovered.length ? 'partial' : 'refreshed',
|
|
272
|
+
detail: `${fresh.size} identity(ies) covered in ${requests} request(s)${uncovered.length ? `; ${uncovered.length} NOT covered (${uncovered.slice(0, 3).join(', ')}${uncovered.length > 3 ? ', ...' : ''}) and will be reported as unknown` : ''}${throttled ? '; the service signalled a rate limit and the run stopped' : ''}${apiKey ? '' : `; no ${KEY_ENV} set (keyless limit: 5 requests per 30 s)`}${keyNote}`,
|
|
273
|
+
identities: wanted.length, requested: need.length, requests, records: records.size, dropped, uncovered, failures: failures.slice(0, 10), throttled, path, cacheProblem: cur.problem,
|
|
274
|
+
});
|
|
275
|
+
}
|
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
|
}
|