@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/1919.index.js +5 -2
  3. package/dist/2862.index.js +29 -9
  4. package/dist/3531.index.js +1 -1
  5. package/dist/4171.index.js +4 -4
  6. package/dist/{8551.index.js → 7033.index.js} +415 -22
  7. package/dist/9560.index.js +3 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +11 -6
  11. package/src/engine.js +6 -1
  12. package/src/language/CLAUDE.md +48 -1
  13. package/src/language/assurance.js +21 -4
  14. package/src/language/context.js +2 -0
  15. package/src/language/cvss.js +91 -0
  16. package/src/language/fix-lifecycle.js +114 -21
  17. package/src/language/haskell-adapter.js +4 -3
  18. package/src/language/haskell-advisory-feed.js +15 -0
  19. package/src/language/haskell-boundaries.js +220 -0
  20. package/src/language/haskell-cpp.js +460 -0
  21. package/src/language/haskell-ir.js +6 -3
  22. package/src/language/haskell-models.js +4 -3
  23. package/src/language/haskell-parser.js +122 -38
  24. package/src/language/haskell-sca.js +10 -6
  25. package/src/language/haskell-supply.js +42 -8
  26. package/src/language/haskell-web.js +128 -23
  27. package/src/language/nix-advisory-feed.js +275 -0
  28. package/src/language/nix-fix.js +162 -24
  29. package/src/language/nix-ir.js +16 -12
  30. package/src/language/nix-sca.js +71 -12
  31. package/src/language/nixos-eval-forms.js +162 -0
  32. package/src/language/nixos-module-resolver.js +346 -51
  33. package/src/language/resolved-pass.js +57 -3
  34. package/src/lsp/server.js +5 -2
  35. package/src/mcp/tools.js +3 -1
  36. package/src/posture/fix-history.js +30 -3
  37. package/src/report/index.js +4 -1
  38. package/dist/1310.index.js +0 -3161
  39. package/dist/2376.index.js +0 -3151
  40. package/dist/3526.index.js +0 -1019
  41. package/dist/7310.index.js +0 -520
  42. package/dist/8520.index.js +0 -13
  43. package/dist/9091.index.js +0 -3135
@@ -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
+ }
@@ -24,7 +24,8 @@ import { analyzeNixScripts } from './nix-script-taint.js';
24
24
  import { analyzeNixSecrets } from './nix-secrets.js';
25
25
  import { analyzeNixBuildTrust } from './nix-build-trust.js';
26
26
  import { analyzeNixosHardening } from './nixos-hardening.js';
27
- import { runFixLifecycle, unifiedDiff, writeWithBackup, undoFix } from './fix-lifecycle.js';
27
+ import { runFixLifecycle, unifiedDiff, writeWithBackup, undoFix, isRootRelativePath } from './fix-lifecycle.js';
28
+ import { resolveNixosConfig } from './nixos-module-resolver.js';
28
29
  import { parseFlakeLock } from './nix-inventory.js';
29
30
 
30
31
  export const NIX_FIX_VERSION = 'nix-fix/1';
@@ -55,30 +56,137 @@ const MANUAL = {
55
56
  };
56
57
  const HASH_MANUAL = new Set(['nix-fetch-missing-hash', 'nix-fetch-fake-hash', 'nix-fetch-floating-rev']);
57
58
 
58
- function planOptionFix(finding, files, fx, source) {
59
- // `optionEvidence` is the option-level evidence a scan carries; `evidence` is that same array on a finding that comes
60
- // straight from the hardening analysis (the scan's own `evidence` field is the list of detectors that agreed).
61
- const evList = Array.isArray(finding.optionEvidence) ? finding.optionEvidence : (Array.isArray(finding.evidence) ? finding.evidence.filter((e) => e && typeof e === 'object') : []);
62
- const evidence = evList[0] || null;
63
- const option = (evidence && evidence.option) || finding.subject;
64
- const winner = evidence && (evidence.sources || []).find((s) => s.role === 'winner');
65
- const file = source ? source.file : (winner ? winner.file : finding.file);
66
- const line = source ? source.line : (winner ? winner.line : finding.line);
67
- const text = files[file];
68
- if (typeof text !== 'string') return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: `the defining file ${file} is not in the supplied tree` };
59
+ // The option each hardening rule judges. Used to pick the right evidence record when a finding carries several.
60
+ const OPTION_OF = {
61
+ 'ssh-root-login': 'services.openssh.settings.PermitRootLogin',
62
+ 'ssh-password-auth': 'services.openssh.settings.PasswordAuthentication',
63
+ 'ssh-empty-passwords': 'services.openssh.settings.PermitEmptyPasswords',
64
+ 'firewall-disabled': 'networking.firewall.enable',
65
+ 'nix-require-sigs-disabled': 'nix.settings.require-sigs',
66
+ 'nix-sandbox-disabled': 'nix.settings.sandbox',
67
+ 'nix-accept-flake-config': 'nix.settings.accept-flake-config',
68
+ };
69
+ // The literal each rule treats as the weakness: the one a conditional branch must hold to be edited.
70
+ const BAD_OF = { 'ssh-root-login': '"yes"', 'ssh-password-auth': 'true', 'ssh-empty-passwords': 'true', 'firewall-disabled': 'false', 'nix-require-sigs-disabled': 'false', 'nix-sandbox-disabled': 'false', 'nix-accept-flake-config': 'true' };
71
+ const literalValue = (to) => (to.startsWith('"') ? to.slice(1, -1) : to === 'true');
72
+
73
+ /** The module a human should put an override line in: the entry configuration. */
74
+ const entryOf = (files, opts) => (opts && opts.entry) || (files['configuration.nix'] !== undefined ? 'configuration.nix' : null);
75
+
76
+ /**
77
+ * The override line to ADD to the entry module when no definition can be edited. A plain assignment conflicts with an
78
+ * equal-priority definition, so the wrapper is chosen from the strongest competing priority: nothing when every
79
+ * definition is weaker than plain, mkForce when plain is the strongest, mkOverride just below anything stronger.
80
+ */
81
+ function overrideSuggestion(option, fx, sources, files, opts) {
82
+ const prios = sources.map((s) => s.priority).filter((n) => Number.isFinite(n));
83
+ const strongest = prios.length ? Math.min(...prios) : 100;
84
+ const base = { file: entryOf(files, opts), option, automatic: false };
85
+ if (strongest > 100) return { ...base, line: `${option} = ${fx.to};`, priority: 100, note: 'every existing definition is weaker than a plain assignment, so no override wrapper is needed.' };
86
+ if (strongest > 50) return { ...base, line: `${option} = lib.mkForce ${fx.to};`, priority: 50, note: 'a plain assignment would conflict with the existing definition at the same priority, so mkForce is required. `lib` must be among the module arguments.' };
87
+ if (strongest > 1) return { ...base, line: `${option} = lib.mkOverride ${strongest - 1} ${fx.to};`, priority: strongest - 1, note: `an existing definition already uses priority ${strongest}, so the override must be stronger than it. \`lib\` must be among the module arguments.` };
88
+ return { ...base, line: null, priority: null, note: 'an existing definition uses the strongest possible priority; nothing can override it from another module.' };
89
+ }
90
+
91
+ const refuse = (status, reason, extra = {}) => ({ ok: false, status, tier: status === 'blocked' ? TIERS.blocked : TIERS.guidance, reason, ...extra });
92
+
93
+ /**
94
+ * Edit the single literal that defines `option` at `file`:`line` in `text`. When several bindings share the line (the branches
95
+ * of one `if`), the one holding the weak literal `bad` is preferred. `skip` reports a definition that already holds the safe value.
96
+ */
97
+ function literalEdit(file, text, line, option, fx, bad) {
69
98
  const parse = parseNix(text, { file });
70
- if (!parse.ast) return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: `${file} does not parse` };
99
+ if (!parse.ast) return { ok: false, reason: `${file} does not parse` };
71
100
  const ir = buildNixIR(parse, { file, source: text });
72
- const cands = ir.bindings.filter((b) => b.pathText === option && b.valueSpan);
73
- const hit = cands.find((b) => b.span && Math.abs(b.span.startLine - line) <= 0) || cands.find((b) => b.span && b.span.startLine <= line && b.span.endLine >= line) || (cands.length === 1 ? cands[0] : null);
74
- if (!hit) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: `no literal definition of ${option} was found at ${file}:${line}: it may be set by a function, an import or an attribute-set merge`, proposal: { option, set: fx.to } };
101
+ const re = fx.kind === 'bool' ? /\b(?:true|false)\b/g : /"(?:[^"\\]|\\.)*"/g;
102
+ const cands = ir.bindings.filter((b) => b.pathText === option && b.valueSpan && b.span);
103
+ const onLine = cands.filter((b) => b.span.startLine === line);
104
+ const within = cands.filter((b) => b.span.startLine <= line && b.span.endLine >= line);
105
+ const pool = onLine.length ? onLine : (within.length ? within : (cands.length === 1 ? cands : []));
106
+ const litOf = (b) => { const t = text.slice(b.valueSpan.startOffset, b.valueSpan.endOffset).match(re) || []; return t.length === 1 ? t[0] : null; };
107
+ const hit = (bad && pool.find((b) => litOf(b) === bad)) || pool.find((b) => litOf(b) !== null && litOf(b) !== fx.to) || pool[0] || null;
108
+ if (!hit) return { ok: false, reason: `no literal definition of ${option} was found at ${file}:${line}: it may be set by a function, an import or an attribute-set merge` };
75
109
  const vs = hit.valueSpan;
76
110
  const current = text.slice(vs.startOffset, vs.endOffset);
77
- const re = fx.kind === 'bool' ? /\b(?:true|false)\b/g : /"(?:[^"\\]|\\.)*"/g;
78
111
  const lits = current.match(re) || [];
79
- if (lits.length !== 1) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: `the definition of ${option} is not a single literal (${current.slice(0, 60)}): the edit would have to choose a branch`, proposal: { option, set: fx.to } };
112
+ if (lits.length !== 1) return { ok: false, reason: `the definition of ${option} at ${file}:${hit.span.startLine} is not a single literal (${current.trim().slice(0, 60)}): the edit would have to choose a branch` };
113
+ if (lits[0] === fx.to) return { ok: true, skip: true, line: hit.span.startLine };
80
114
  const next = current.replace(re, fx.to);
81
- return { ok: true, file, before: text, after: splice(text, vs.startOffset, vs.endOffset, next), ruleId: `nix-set-${option}`, label: 'FULL', explanation: `${option} = ${current.trim()} -> ${next.trim()} at ${file}:${hit.span.startLine}, the definition that wins by priority.`, consequences: fx.consequences, tier: TIERS.edit, behavior: { kind: 'option', option, from: current.trim(), to: next.trim() } };
115
+ return { ok: true, after: splice(text, vs.startOffset, vs.endOffset, next), from: current.trim(), to: next.trim(), line: hit.span.startLine, literal: lits[0] };
116
+ }
117
+
118
+ function planOptionFix(finding, files, fx, source, opts = {}) {
119
+ // `optionEvidence` is the option-level evidence a scan carries; `evidence` is that same array on a finding that comes
120
+ // straight from the hardening analysis (the scan's own `evidence` field is the list of detectors that agreed).
121
+ const evList = Array.isArray(finding.optionEvidence) ? finding.optionEvidence : (Array.isArray(finding.evidence) ? finding.evidence.filter((e) => e && typeof e === 'object') : []);
122
+ const evidence = evList.find((e) => e && e.option === OPTION_OF[finding.rule]) || evList[0] || null;
123
+ const option = (evidence && evidence.option) || finding.subject;
124
+ const sources = (evidence && Array.isArray(evidence.sources)) ? evidence.sources : [];
125
+ const bad = BAD_OF[finding.rule] || null;
126
+ const proposal = (extra = {}) => ({ option, set: fx.to, ...extra });
127
+
128
+ // Findings that carry no per-definition evidence keep the single-file behaviour: edit the reported line.
129
+ if (!sources.length) {
130
+ const file = source ? source.file : finding.file;
131
+ const line = source ? source.line : finding.line;
132
+ if (typeof files[file] !== 'string') return refuse('blocked', `the defining file ${file} is not in the supplied tree`);
133
+ const r = literalEdit(file, files[file], line, option, fx, bad);
134
+ if (!r.ok) return refuse(/does not parse/.test(r.reason) ? 'blocked' : 'manual', r.reason, { proposal: proposal() });
135
+ if (r.skip) return refuse('manual', `the definition of ${option} at ${file}:${r.line} already holds ${fx.to}`, { proposal: proposal() });
136
+ return optionPlanResult(option, fx, [{ file, before: files[file], after: r.after, from: r.from, to: r.to, line: r.line }], [], bad);
137
+ }
138
+
139
+ // Everything that contributes to the effective value: the definitions that win, and those that apply only under a
140
+ // condition. Definitions another one overrides do not contribute and are left alone.
141
+ const live = sources.filter((s) => s.role === 'winner' || s.role === 'conditional');
142
+ const shadowed = sources.filter((s) => s.role === 'shadowed');
143
+ const override = () => overrideSuggestion(option, fx, sources, files, opts);
144
+ if (!live.length) return refuse('manual', `no contributing definition of ${option} could be identified`, { proposal: proposal({ override: override() }) });
145
+
146
+ // The caller chose a definition that another one overrides: changing it cannot change the effective value.
147
+ if (source) {
148
+ const chosen = sources.find((s) => s.file === source.file && s.line === source.line);
149
+ if (chosen && chosen.role === 'shadowed') {
150
+ const by = live.map((w) => `${w.file}:${w.line} (${w.priorityLabel || w.priority})`).join(', ');
151
+ return refuse('blocked', `${source.file}:${source.line} is overridden by ${by}: editing it would leave the effective value of ${option} unchanged`, { proposal: proposal({ editInstead: live.map((w) => ({ file: w.file, line: w.line })), override: override() }) });
152
+ }
153
+ }
154
+
155
+ // Every contributing definition must be editable: inside the project, present, a single literal. Several definitions at the
156
+ // winning priority agree today and the module system rejects a mix, so they are all changed or none is. A conditional
157
+ // branch is edited only where it holds the weak literal; the verification then proves every branch is safe.
158
+ const working = { ...files };
159
+ const edits = [];
160
+ for (const w of live) {
161
+ if (!isRootRelativePath(w.file)) return refuse('manual', `the winning definition of ${option} is in ${w.file}, outside the project root, so it is not edited`, { proposal: proposal({ outsideRoot: w.file, override: override() }) });
162
+ if (typeof working[w.file] !== 'string') return refuse('manual', `the winning definition of ${option} is in ${w.file}, which is not in the supplied tree`, { proposal: proposal({ override: override() }) });
163
+ const r = literalEdit(w.file, working[w.file], w.line, option, fx, bad);
164
+ if (!r.ok) return refuse(/does not parse/.test(r.reason) ? 'blocked' : 'manual', r.reason, { proposal: proposal({ override: override() }) });
165
+ if (r.skip || (w.role === 'conditional' && bad && r.literal !== bad)) continue;
166
+ const prior = edits.find((e) => e.file === w.file);
167
+ if (prior) prior.after = r.after; else edits.push({ file: w.file, before: working[w.file], after: r.after, from: r.from, to: r.to, line: r.line });
168
+ working[w.file] = r.after;
169
+ }
170
+ if (!edits.length) return refuse('manual', `no contributing definition of ${option} holds the weak value, so there is nothing to edit`, { proposal: proposal({ override: override() }) });
171
+ return optionPlanResult(option, fx, edits, shadowed, bad);
172
+ }
173
+
174
+ function optionPlanResult(option, fx, edits, shadowed, bad = null) {
175
+ const first = edits[0];
176
+ const where = edits.map((e) => `${e.file}:${e.line}`).join(', ');
177
+ const multi = edits.length > 1;
178
+ const notes = [...fx.consequences];
179
+ if (shadowed.length) notes.push(`Weaker definitions of ${option} remain at ${shadowed.map((s) => `${s.file}:${s.line}`).join(', ')}; they are overridden and unchanged.`);
180
+ if (multi) notes.push(`${edits.length} files are edited together (${edits.map((e) => e.file).join(', ')}) because each holds a definition at the winning priority; undo restores all of them.`);
181
+ return {
182
+ ok: true, file: first.file, before: first.before, after: first.after,
183
+ edits: edits.map((e) => ({ file: e.file, before: e.before, after: e.after })),
184
+ ruleId: `nix-set-${option}`, label: 'FULL',
185
+ explanation: `${option} = ${first.from} -> ${first.to} at ${where}, the definition${multi ? 's' : ''} that win${multi ? '' : 's'} by priority.`,
186
+ consequences: notes, tier: TIERS.edit,
187
+ behavior: { kind: 'option', option, from: first.from, to: first.to, files: edits.map((e) => e.file) },
188
+ expectEffective: { option, value: literalValue(fx.to), ...(bad ? { bad: literalValue(bad) } : {}) },
189
+ };
82
190
  }
83
191
 
84
192
  function planTransportFix(finding, files) {
@@ -170,7 +278,7 @@ export function planNixFix(finding, files, opts = {}) {
170
278
  const r = finding.rule;
171
279
  if (r === 'nix-shell-injection' || r === 'nix-escape-wrong-context') return planShellFix(finding, files);
172
280
  if (r === 'nix-service-env-shell') return planEnvQuoteFix(finding, files);
173
- if (OPTION_FIXES[r]) return planOptionFix(finding, files, OPTION_FIXES[r], opts.source || null);
281
+ if (OPTION_FIXES[r]) return planOptionFix(finding, files, OPTION_FIXES[r], opts.source || null, opts);
174
282
  if (TRANSPORT_RULES.has(r)) return planTransportFix(finding, files);
175
283
  if (MANUAL[r]) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: 'moving a secret out of the store changes where the credential lives and who can read it: it needs a human migration, never an automatic rewrite', proposal: { steps: MANUAL[r], rotate: r !== 'nix-secret-log', manager: 'sops-nix or agenix' } };
176
284
  if (HASH_MANUAL.has(r)) return { ok: false, status: 'manual', tier: TIERS.blocked, reason: 'the correct hash or revision can only be obtained by fetching the source, which a scan never does; a placeholder hash would be a fabricated value', proposal: { steps: ['Run `nix-prefetch-url` / `nix store prefetch-file` (or build once with lib.fakeHash and read the reported hash) from a trusted machine.', 'Pin a full revision, not a branch.', 'Paste the real hash.'], neverDo: 'insert a made-up or all-zero hash' } };
@@ -193,16 +301,46 @@ export async function rescanNix(files) {
193
301
  }
194
302
  const matchKey = (f) => `${f.rule || f.cwe}|${f.subject || f.attrPath || ''}|${f.file}`;
195
303
  const syntaxGate = (plan) => {
196
- const a = parseNix(plan.before, { file: plan.file }); const b = parseNix(plan.after, { file: plan.file });
197
- const na = (a.errors || []).length, nb = (b.errors || []).length;
198
- return { ok: !!b.ast && nb <= na, errorsBefore: na, errorsAfter: nb, detail: nb <= na ? 'no new syntax errors' : `${nb - na} new syntax error(s)` };
304
+ const edits = Array.isArray(plan.edits) && plan.edits.length ? plan.edits : [{ file: plan.file, before: plan.before, after: plan.after }];
305
+ let na = 0; let nb = 0; let ok = true;
306
+ for (const e of edits) {
307
+ const a = parseNix(e.before, { file: e.file }); const b = parseNix(e.after, { file: e.file });
308
+ na += (a.errors || []).length; nb += (b.errors || []).length;
309
+ if (!b.ast || (b.errors || []).length > (a.errors || []).length) ok = false;
310
+ }
311
+ return { ok, errorsBefore: na, errorsAfter: nb, detail: ok ? 'no new syntax errors' : `${nb - na} new syntax error(s)` };
199
312
  };
200
313
 
314
+ /**
315
+ * The effective-configuration gate of an option fix. The rescan only proves a finding stopped being REPORTED, and an edit that
316
+ * leaves two equal-priority definitions disagreeing does that too (the module system then rejects the configuration). So the
317
+ * option is resolved again over the patched tree and must be a decided `set` value equal to the intended one.
318
+ */
319
+ function makeEffectiveCheck(entry) {
320
+ return (plan, patched) => {
321
+ const want = plan.expectEffective;
322
+ if (!want) return { ok: true, ran: false, detail: 'not an option fix' };
323
+ const multi = Array.isArray(plan.edits) && plan.edits.length > 1;
324
+ if (!entry) return multi ? { ok: false, ran: false, detail: `no entry configuration was found to resolve ${want.option} over the patched tree, and ${plan.edits.length} files were edited` } : { ok: true, ran: false, detail: 'no entry configuration to resolve; the single definition was edited in place' };
325
+ const nixOnly = Object.fromEntries(Object.entries(patched).filter(([p, t]) => /\.nix$/.test(p) && typeof t === 'string'));
326
+ let res;
327
+ try { res = resolveNixosConfig({ entry, files: nixOnly }).lookup(want.option); } catch (e) { return { ok: false, ran: true, detail: `the patched configuration could not be resolved (${e.message})` }; }
328
+ // A value that depends on a condition has no single answer; the fix holds when NO branch can still produce the weak value.
329
+ if (res && res.status === 'conditional' && Array.isArray(res.possibleValues) && res.possibleValues.length && want.bad !== undefined) {
330
+ if (res.possibleValues.includes(want.bad)) return { ok: false, ran: true, option: want.option, detail: `${want.option} can still be ${JSON.stringify(want.bad)} under some condition after the patch` };
331
+ return { ok: true, ran: true, option: want.option, possibleValues: res.possibleValues, detail: `${want.option} is conditional and no branch can produce ${JSON.stringify(want.bad)} (possible: ${res.possibleValues.map((v) => JSON.stringify(v)).join(', ')})` };
332
+ }
333
+ if (!res || res.status !== 'set') return { ok: false, ran: true, option: want.option, status: res && res.status, detail: `${want.option} is "${res && res.status}" after the patch${res && res.reason ? ` (${res.reason})` : ''}, not a decided value` };
334
+ if (res.value !== want.value) return { ok: false, ran: true, option: want.option, detail: `${want.option} is ${JSON.stringify(res.value)} after the patch, not ${JSON.stringify(want.value)}` };
335
+ return { ok: true, ran: true, option: want.option, value: res.value, detail: `${want.option} resolves to ${JSON.stringify(res.value)} over the patched tree` };
336
+ };
337
+ }
338
+
201
339
  /** Validate (and optionally apply) a fix through the shared lifecycle. */
202
340
  export async function validateNixFix(finding, o) {
203
341
  const plan = planNixFix(finding, o.files, o);
204
342
  if (!plan.ok) return { status: plan.status || 'unsupported', applied: false, tier: plan.tier, reason: plan.reason, proposal: plan.proposal || null, ...plan };
205
- const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
343
+ const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, effectiveCheck: makeEffectiveCheck(entryOf(o.files, o)), writeFile: o.writeFile, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
206
344
  return { ...res, consequences: plan.consequences || [], tier: res.status === 'blocked' ? TIERS.blocked : plan.tier, explanation: plan.explanation };
207
345
  }
208
346
 
@@ -165,21 +165,23 @@ class IrBuilder {
165
165
  if (depth > this.b.maxIrDepth) { this.budget('maxIrDepth'); return []; }
166
166
  switch (n.type) {
167
167
  case 'attrset': return this.flattenAttrs(n, ctx, depth);
168
+ // `evalWrap` remembers the OUTERMOST let/with that wraps a leaf value (through nothing but let, with, assert and parentheses), so
169
+ // the module resolver can evaluate the whole wrapper with correct scoping instead of the bare body. Any other combinator clears it.
168
170
  case 'let': {
169
171
  const out = this.recordScoped(n, 'let', ctx);
170
- return out.concat(this.flatten(n.body, ctx, depth + 1));
172
+ return out.concat(this.flatten(n.body, { ...ctx, evalWrap: ctx.evalWrap || { span: n.span, withs: ctx.withs } }, depth + 1));
171
173
  }
172
- case 'with': return this.flatten(n.body, { ...ctx, withs: [...ctx.withs, this.snippet(n.env)] }, depth + 1);
173
- case 'assert': return this.flatten(n.body, ctx, depth + 1);
174
+ case 'with': return this.flatten(n.body, { ...ctx, withs: [...ctx.withs, this.snippet(n.env)], evalWrap: ctx.evalWrap || { span: n.span, withs: ctx.withs } }, depth + 1);
175
+ case 'assert': return this.flatten(n.body, { ...ctx, evalWrap: ctx.evalWrap || { span: n.span, withs: ctx.withs } }, depth + 1);
174
176
  case 'if': {
175
177
  const c = { kind: 'if', span: n.cond.span, text: this.snippet(n.cond) };
176
- return this.flatten(n.then, { ...ctx, conditions: [...ctx.conditions, { ...c, branch: 'then' }] }, depth + 1)
177
- .concat(this.flatten(n.else, { ...ctx, conditions: [...ctx.conditions, { ...c, branch: 'else' }] }, depth + 1));
178
+ return this.flatten(n.then, { ...ctx, evalWrap: null, conditions: [...ctx.conditions, { ...c, branch: 'then' }] }, depth + 1)
179
+ .concat(this.flatten(n.else, { ...ctx, evalWrap: null, conditions: [...ctx.conditions, { ...c, branch: 'else' }] }, depth + 1));
178
180
  }
179
181
  case 'binop':
180
182
  if (n.op === '//') {
181
- const left = this.flatten(n.left, ctx, depth + 1);
182
- const right = this.flatten(n.right, ctx, depth + 1);
183
+ const left = this.flatten(n.left, { ...ctx, evalWrap: null }, depth + 1);
184
+ const right = this.flatten(n.right, { ...ctx, evalWrap: null }, depth + 1);
183
185
  const over = new Set();
184
186
  for (const r of right) if (r.path.length > ctx.prefix.length && r.path[ctx.prefix.length] !== null) over.add(r.path[ctx.prefix.length]);
185
187
  const kept = [];
@@ -196,17 +198,17 @@ class IrBuilder {
196
198
  const name = calleeName(fn);
197
199
  if (name === 'mkMerge' && args.length === 1 && unparen(args[0]).type === 'list') {
198
200
  let out = [];
199
- for (const it of unparen(args[0]).items) out = out.concat(this.flatten(it, { ...ctx, merge: 'mkMerge' }, depth + 1));
201
+ for (const it of unparen(args[0]).items) out = out.concat(this.flatten(it, { ...ctx, evalWrap: null, merge: 'mkMerge' }, depth + 1));
200
202
  return out;
201
203
  }
202
204
  if (COND_WRAPPERS.has(name) && args.length === 2) {
203
205
  const cond = { kind: name, span: args[0].span, text: this.snippet(args[0]), branch: 'then' };
204
- return this.flatten(args[1], { ...ctx, conditions: [...ctx.conditions, cond] }, depth + 1);
206
+ return this.flatten(args[1], { ...ctx, evalWrap: null, conditions: [...ctx.conditions, cond] }, depth + 1);
205
207
  }
206
208
  if (PRIORITY_WRAPPERS.has(name) && args.length >= 1) {
207
209
  // mkOverride carries its numeric priority as the first argument; keep it so the module resolver can rank it
208
210
  const priorityArg = name === 'mkOverride' && args.length >= 2 ? this.summarize(args[0]) : null;
209
- return this.flatten(args[args.length - 1], { ...ctx, priority: name, priorityArg }, depth + 1);
211
+ return this.flatten(args[args.length - 1], { ...ctx, evalWrap: null, priority: name, priorityArg }, depth + 1);
210
212
  }
211
213
  // a file that is just `mkDerivation { ... }` / `callPackage ./x.nix { ... }`: the last attrset argument is the configuration
212
214
  if (!ctx.prefix.length && args.length) {
@@ -229,6 +231,8 @@ class IrBuilder {
229
231
  withs: ctx.withs, overridden: false, update: false, callee: ctx.callee || null,
230
232
  });
231
233
  if (rec && ctx.priorityArg) rec.priorityArg = ctx.priorityArg;
234
+ if (rec && ctx.evalWrap) { rec.evalSpan = ctx.evalWrap.span; rec.evalWiths = ctx.evalWrap.withs; }
235
+ if (rec && ctx.inRec) rec.inRec = true;
232
236
  if (rec && n.type === 'lambda') this.noteBoundaryLambda(n, ctx);
233
237
  return rec ? [rec] : [];
234
238
  }
@@ -262,7 +266,7 @@ class IrBuilder {
262
266
  if (dynamic) {
263
267
  for (const s of b.path) if (s.kind !== 'static') this.gap('dynamic-attribute', `attribute name computed at evaluation time (${this.snippet({ start: s.span.startOffset, end: s.span.endOffset }) || 'interpolated'})`, s.span);
264
268
  }
265
- const sub = { ...ctx, prefix: [...ctx.prefix, ...segs], span: b.span, origin: 'attr' };
269
+ const sub = { ...ctx, prefix: [...ctx.prefix, ...segs], span: b.span, origin: 'attr', evalWrap: null, inRec: ctx.inRec || !!n.rec };
266
270
  out = out.concat(this.flatten(b.value, sub, depth + 1));
267
271
  }
268
272
  return out;
@@ -280,7 +284,7 @@ class IrBuilder {
280
284
  continue;
281
285
  }
282
286
  const { path: segs, dynamic } = staticPath(b.path);
283
- const r = this.addBinding({ path: segs, pathText: segs.map((p) => (p === null ? '${...}' : p)).join('.'), value: this.summarize(b.value), span: b.span, valueSpan: b.value.span, origin: 'let', scope, dynamic, conditions: ctx.conditions, priority: null, merge: null, withs: ctx.withs, overridden: false, update: false });
287
+ const r = this.addBinding({ letSpan: n.span, path: segs, pathText: segs.map((p) => (p === null ? '${...}' : p)).join('.'), value: this.summarize(b.value), span: b.span, valueSpan: b.value.span, origin: 'let', scope, dynamic, conditions: ctx.conditions, priority: null, merge: null, withs: ctx.withs, overridden: false, update: false });
284
288
  if (r) out.push(r);
285
289
  if (dynamic) this.gap('dynamic-attribute', 'dynamic attribute name in let binding', b.span);
286
290
  }