@clear-capabilities/agentic-security-scanner 0.158.0 → 0.159.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/1919.index.js +5 -2
  3. package/dist/2862.index.js +29 -9
  4. package/dist/3531.index.js +1 -1
  5. package/dist/4171.index.js +4 -4
  6. package/dist/{8551.index.js → 7033.index.js} +415 -22
  7. package/dist/9560.index.js +3 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +6 -4
  11. package/src/engine.js +6 -1
  12. package/src/language/CLAUDE.md +48 -1
  13. package/src/language/assurance.js +21 -4
  14. package/src/language/context.js +2 -0
  15. package/src/language/cvss.js +91 -0
  16. package/src/language/fix-lifecycle.js +114 -21
  17. package/src/language/haskell-adapter.js +4 -3
  18. package/src/language/haskell-advisory-feed.js +15 -0
  19. package/src/language/haskell-boundaries.js +220 -0
  20. package/src/language/haskell-cpp.js +460 -0
  21. package/src/language/haskell-ir.js +6 -3
  22. package/src/language/haskell-models.js +4 -3
  23. package/src/language/haskell-parser.js +122 -38
  24. package/src/language/haskell-sca.js +10 -6
  25. package/src/language/haskell-supply.js +42 -8
  26. package/src/language/haskell-web.js +128 -23
  27. package/src/language/nix-advisory-feed.js +275 -0
  28. package/src/language/nix-fix.js +162 -24
  29. package/src/language/nix-ir.js +16 -12
  30. package/src/language/nix-sca.js +71 -12
  31. package/src/language/nixos-eval-forms.js +162 -0
  32. package/src/language/nixos-module-resolver.js +346 -51
  33. package/src/language/resolved-pass.js +57 -3
  34. package/src/lsp/server.js +5 -2
  35. package/src/mcp/tools.js +3 -1
  36. package/src/posture/fix-history.js +30 -3
  37. package/src/report/index.js +4 -1
  38. package/dist/1310.index.js +0 -3161
  39. package/dist/2376.index.js +0 -3151
  40. package/dist/3526.index.js +0 -1019
  41. package/dist/7310.index.js +0 -520
  42. package/dist/8520.index.js +0 -13
  43. package/dist/9091.index.js +0 -3135
@@ -1,8 +1,8 @@
1
- export const id = 8551;
2
- export const ids = [8551];
1
+ export const id = 7033;
2
+ export const ids = [7033];
3
3
  export const modules = {
4
4
 
5
- /***/ 38551:
5
+ /***/ 37033:
6
6
  /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
7
 
8
8
 
@@ -11,12 +11,13 @@ __webpack_require__.d(__webpack_exports__, {
11
11
  analyzeNixClosure: () => (/* binding */ analyzeNixClosure),
12
12
  nixClosureOf: () => (/* binding */ nixClosureOf),
13
13
  nixHaskellPackageNames: () => (/* binding */ nixHaskellPackageNames),
14
+ prefetchNixAdvisoryFeed: () => (/* binding */ prefetchNixAdvisoryFeed),
14
15
  resolvedHackageComponents: () => (/* binding */ resolvedHackageComponents),
15
16
  resolvedHaskellGraph: () => (/* binding */ resolvedHaskellGraph),
16
17
  runSelectedNixEval: () => (/* binding */ runSelectedNixEval)
17
18
  });
18
19
 
19
- // UNUSED EXPORTS: RESOLVED_PASS_VERSION, configuredNixAdvisories, mergeEvaluationHealth
20
+ // UNUSED EXPORTS: RESOLVED_PASS_VERSION, configuredNixAdvisories, configuredNixMeta, mergeEvaluationHealth
20
21
 
21
22
  // EXTERNAL MODULE: external "node:fs"
22
23
  var external_node_fs_ = __webpack_require__(73024);
@@ -363,8 +364,289 @@ function signedBody(ex) { return JSON.stringify({ schema: ex.schema, provenance:
363
364
 
364
365
  // EXTERNAL MODULE: ./src/language/haskell-sca.js
365
366
  var haskell_sca = __webpack_require__(2437);
367
+ // EXTERNAL MODULE: ./src/language/cvss.js
368
+ var cvss = __webpack_require__(32146);
366
369
  // EXTERNAL MODULE: ./src/language/nix-parser.js
367
370
  var nix_parser = __webpack_require__(54352);
371
+ // EXTERNAL MODULE: external "node:path"
372
+ var external_node_path_ = __webpack_require__(76760);
373
+ ;// CONCATENATED MODULE: ./src/language/nix-advisory-feed.js
374
+ // Live advisory feed for the upstream software inside a Nix closure (NIX-009 follow-on).
375
+ //
376
+ // OSV has no nixpkgs ecosystem, so the Hackage approach (ask OSV by ecosystem and package name) does not carry over. The source used
377
+ // 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
378
+ // package declares its upstream identity as a CPE in meta.identifiers and NVD is the one public, machine-readable feed keyed by it.
379
+ // 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
380
+ // not something this feed is keyed by, so it is reported unknown, never clean.
381
+ //
382
+ // Contract (the same as haskell-advisory-feed.js, in the order it matters):
383
+ // * Opt in. Nothing here touches the network unless AGENTIC_SECURITY_NIX_ADVISORIES_LIVE=1, and never when AGENTIC_SECURITY_OFFLINE=1.
384
+ // An operator-pinned snapshot (AGENTIC_SECURITY_NIX_ADVISORIES) is used as given and is never refreshed over. A hand-written
385
+ // nix-advisories.json in the operator configuration directory is never overwritten either.
386
+ // * Coverage is recorded, not assumed. The snapshot lists every vendor:product it fully read and when. One it did not read, or read
387
+ // only in part, is NOT covered and the matcher reports it unknown.
388
+ // * Failure degrades. An unreachable or throttling feed leaves the previous snapshot in place; the scan runs on it with its age stated.
389
+ // * Everything from the wire is untrusted: the identity is validated before it reaches a URL, responses are size-capped, a record
390
+ // that does not name the requested product is dropped, and a cache whose records fail their own hashes is discarded.
391
+ // * Rate limits. NVD allows 5 requests per rolling 30 seconds without an API key and 50 with one (a key is free; set
392
+ // AGENTIC_SECURITY_NVD_API_KEY). Requests are spaced to stay under that, a throttling response stops the run instead of retrying,
393
+ // and the number of requests per scan is capped, so a large closure fills in over several scans rather than hammering the service.
394
+
395
+
396
+
397
+
398
+
399
+
400
+ const FEED_ENV = 'AGENTIC_SECURITY_NIX_ADVISORIES_LIVE';
401
+ const PINNED_ENV = 'AGENTIC_SECURITY_NIX_ADVISORIES';
402
+ const KEY_ENV = 'AGENTIC_SECURITY_NVD_API_KEY';
403
+ const SNAPSHOT_FILE = 'nix-advisories.json';
404
+ const SNAPSHOT_SCHEMA = 'nix-advisories-live/1';
405
+ const NVD_BASE = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
406
+
407
+ const DEFAULTS = Object.freeze({
408
+ ttlMs: 24 * 3600 * 1000, // a covered identity is not queried again inside this window
409
+ timeoutMs: 20000,
410
+ maxProducts: 60, // identities considered per scan
411
+ maxRequestsKeyless: 20, // about 2 minutes at the keyless spacing
412
+ maxRequestsKeyed: 120,
413
+ intervalKeylessMs: 6500, // 5 per 30 s, with margin
414
+ intervalKeyedMs: 700, // 50 per 30 s, with margin
415
+ pageSize: 2000, // the NVD maximum
416
+ maxPages: 3,
417
+ maxResponseBytes: 32 << 20,
418
+ maxSnapshotRecords: 30000,
419
+ });
420
+
421
+ 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
422
+ const CVE_ID = /^CVE-\d{4}-\d{4,12}$/;
423
+ const VERSION_OK = /^[A-Za-z0-9][A-Za-z0-9._+~-]{0,63}$/;
424
+ const KEY_OK = /^[0-9a-fA-F-]{20,64}$/;
425
+
426
+ /** '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. */
427
+ function cpeKey(x) {
428
+ let v = null; let p = null;
429
+ if (x && typeof x === 'object') {
430
+ if (typeof x.vendor === 'string' && typeof x.product === 'string') { v = x.vendor; p = x.product; }
431
+ else if (typeof x.cpe === 'string') return cpeKey(x.cpe);
432
+ else return null;
433
+ } else if (typeof x === 'string') {
434
+ if (x.startsWith('cpe:2.3:')) { const parts = x.split(':'); if (parts.length < 5) return null; v = parts[3]; p = parts[4]; }
435
+ else { const parts = x.split(':'); if (parts.length !== 2) return null; [v, p] = parts; }
436
+ } else return null;
437
+ v = v.toLowerCase(); p = p.toLowerCase();
438
+ return PART.test(v) && PART.test(p) ? `${v}:${p}` : null;
439
+ }
440
+ const validCpeKey = (k) => typeof k === 'string' && cpeKey(k) === k;
441
+
442
+ let last = null;
443
+ /** What the most recent refresh in this process did, so a "no feed" reason can say why. */
444
+ const getLastRefresh = () => last;
445
+ const _resetLastRefresh = () => { last = null; };
446
+
447
+ function liveFeedEnabled(env = process.env) {
448
+ return env[FEED_ENV] === '1' && env.AGENTIC_SECURITY_OFFLINE !== '1';
449
+ }
450
+ function snapshotPath(env = process.env, dir = null) { return (0,external_node_path_.join)(dir || (0,trusted_inputs/* operatorConfigDir */.Us)(env), SNAPSHOT_FILE); }
451
+
452
+ const nix_advisory_feed_sha256 = (s) => (0,external_node_crypto_.createHash)('sha256').update(s).digest('hex');
453
+
454
+ /**
455
+ * Validate a snapshot written by this module. Returns {ok:true, records, covered, generatedAt} or {ok:false, reason}.
456
+ * `foreign` marks a parseable file that is not ours (a hand-written operator snapshot): not an error, and never overwritten.
457
+ */
458
+ function loadLiveSnapshot(raw) {
459
+ if (!raw || typeof raw !== 'object' || raw.schema !== SNAPSHOT_SCHEMA) return { ok: false, foreign: true, reason: 'not a live-feed snapshot' };
460
+ 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' };
461
+ for (const rec of raw.records) {
462
+ if (!rec || typeof rec.id !== 'string' || !CVE_ID.test(rec.id)) return { ok: false, reason: 'a record has no valid identifier' };
463
+ if (raw.recordHashes[rec.id] !== nix_advisory_feed_sha256(JSON.stringify(rec))) return { ok: false, reason: `record ${rec.id} does not match its recorded hash` };
464
+ }
465
+ const covered = {};
466
+ if (!raw.covered || typeof raw.covered !== 'object' || Array.isArray(raw.covered)) return { ok: false, reason: 'the coverage table is missing or malformed' };
467
+ 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; }
468
+ return { ok: true, records: raw.records, covered, generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : null };
469
+ }
470
+
471
+ function readCurrent(path) {
472
+ let raw;
473
+ try { raw = JSON.parse((0,external_node_fs_.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 }; }
474
+ const r = loadLiveSnapshot(raw);
475
+ if (r.foreign) return { records: new Map(), covered: {}, problem: null, foreign: true };
476
+ if (!r.ok) return { records: new Map(), covered: {}, problem: `the cached snapshot was discarded: ${r.reason}`, foreign: false };
477
+ return { records: new Map(r.records.map((x) => [x.id, x])), covered: r.covered, problem: null, foreign: false };
478
+ }
479
+
480
+ const splitCpe = (s) => s.split(/(?<!\\):/);
481
+ const isoOrNull = (t) => {
482
+ if (typeof t !== 'string') return null;
483
+ const ms = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(t) ? t : `${t}Z`); // NVD timestamps carry no zone and are UTC
484
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
485
+ };
486
+
487
+ /**
488
+ * One NVD CVE object -> an OSV-style record limited to the requested vendor:product, or null when the record does not name it
489
+ * (or is rejected, or carries nothing usable). Only `vulnerable` matches of the requested product are read; operating-system or
490
+ * hardware conditions on a combined configuration are NOT evaluated (see the guide).
491
+ * A start bound that is exclusive is carried as an `introduced_excluding` event, which the Nix matcher understands.
492
+ */
493
+ function nvdToOsv(cve, key) {
494
+ if (!cve || typeof cve !== 'object' || typeof cve.id !== 'string' || !CVE_ID.test(cve.id)) return null;
495
+ if (typeof cve.vulnStatus === 'string' && /^rejected$/i.test(cve.vulnStatus)) return null;
496
+ const ranges = []; const versions = [];
497
+ for (const cfg of Array.isArray(cve.configurations) ? cve.configurations : []) {
498
+ for (const node of Array.isArray(cfg && cfg.nodes) ? cfg.nodes : []) {
499
+ if (node && node.negate === true) continue;
500
+ for (const m of Array.isArray(node && node.cpeMatch) ? node.cpeMatch : []) {
501
+ if (!m || m.vulnerable !== true || typeof m.criteria !== 'string' || !m.criteria.startsWith('cpe:2.3:')) continue;
502
+ const parts = splitCpe(m.criteria);
503
+ if (parts.length < 6 || !['a', 'o', '*'].includes(parts[2]) || `${parts[3]}:${parts[4]}` !== key) continue;
504
+ const ver = parts[5]; const upd = parts[6];
505
+ if (ver === '*') {
506
+ const s1 = m.versionStartIncluding; const s2 = m.versionStartExcluding; const e1 = m.versionEndIncluding; const e2 = m.versionEndExcluding;
507
+ if ([s1, s2, e1, e2].some((x) => x !== undefined && (typeof x !== 'string' || !VERSION_OK.test(x)))) continue;
508
+ const events = [];
509
+ if (s1) events.push({ introduced: s1 }); else if (s2) events.push({ introduced_excluding: s2 }); else events.push({ introduced: '0' });
510
+ if (e2) events.push({ fixed: e2 }); else if (e1) events.push({ last_affected: e1 });
511
+ ranges.push({ type: 'ECOSYSTEM', events });
512
+ } else if (ver !== '-' && ver !== '?') {
513
+ const v = upd && upd !== '*' && upd !== '-' ? `${ver}-${upd}` : ver;
514
+ if (VERSION_OK.test(v)) versions.push(v);
515
+ }
516
+ }
517
+ }
518
+ }
519
+ if (!ranges.length && !versions.length) return null;
520
+ const desc = (Array.isArray(cve.descriptions) ? cve.descriptions : []).find((d) => d && d.lang === 'en' && typeof d.value === 'string');
521
+ 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);
522
+ return {
523
+ id: cve.id, aliases: [], summary: desc ? desc.value.slice(0, 400) : '', published: isoOrNull(cve.published), modified: isoOrNull(cve.lastModified),
524
+ affected: [{ package: { ecosystem: 'NVD-CPE', name: key.split(':')[1], cpe: key }, ranges, versions }],
525
+ references: refs.map((url) => ({ type: 'WEB', url })), database_specific: { source: 'nvd-cve-2.0' },
526
+ };
527
+ }
528
+
529
+ const sleepReal = (ms) => new Promise((r) => setTimeout(r, ms));
530
+
531
+ async function getJson(fetchImpl, url, headers, timeoutMs, maxBytes) {
532
+ const resp = await fetchImpl(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
533
+ if (!resp) throw Object.assign(new Error('no response'), { http: 0 });
534
+ if (resp.status !== 200) throw Object.assign(new Error(`HTTP ${resp.status}`), { http: resp.status });
535
+ const text = await resp.text();
536
+ if (Buffer.byteLength(text) > maxBytes) throw new Error('response too large');
537
+ return JSON.parse(text);
538
+ }
539
+
540
+ /**
541
+ * Refresh the operator's Nix advisory snapshot for the given CPE identities ('vendor:product' keys or anything cpeKey accepts).
542
+ * Never throws. Returns a plain result; the snapshot file is rewritten only when something was read.
543
+ *
544
+ * @param {Array<string|object>} identities
545
+ * @param {{env?: object, fetchImpl?: Function, now?: number, dir?: string, sleep?: Function}} [opts]
546
+ */
547
+ async function refreshNixAdvisories(identities, opts = {}) {
548
+ const env = opts.env || process.env;
549
+ const cfg = { ...DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([k]) => k in DEFAULTS)) };
550
+ const now = opts.now ?? Date.now();
551
+ const done = (r) => { last = { ...r, at: new Date(now).toISOString() }; return last; };
552
+
553
+ 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` });
554
+ 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` });
555
+ if (env.AGENTIC_SECURITY_OFFLINE === '1') return done({ status: 'offline', detail: 'AGENTIC_SECURITY_OFFLINE=1: no advisory data was fetched' });
556
+
557
+ const all = (identities || []).map(cpeKey);
558
+ const invalid = all.filter((k) => !k).length;
559
+ const wanted = [...new Set(all.filter(Boolean))].sort().slice(0, cfg.maxProducts);
560
+ 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 });
561
+
562
+ const path = snapshotPath(env, opts.dir);
563
+ const cur = readCurrent(path);
564
+ 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` });
565
+ const need = wanted.filter((k) => { const t = Date.parse(cur.covered[k]); return !Number.isFinite(t) || now - t > cfg.ttlMs || t > now + 3600000; });
566
+ 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 });
567
+
568
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
569
+ if (typeof fetchImpl !== 'function') return done({ status: 'failed', detail: 'no fetch implementation is available in this runtime' });
570
+ const sleep = opts.sleep || sleepReal;
571
+ const apiKey = typeof env[KEY_ENV] === 'string' && KEY_OK.test(env[KEY_ENV]) ? env[KEY_ENV] : null;
572
+ const keyNote = env[KEY_ENV] && !apiKey ? ` (${KEY_ENV} is set but is not a valid key; it was ignored)` : '';
573
+ const headers = { 'User-Agent': 'agentic-security-advisory-feed', Accept: 'application/json', ...(apiKey ? { apiKey } : {}) };
574
+ const interval = apiKey ? cfg.intervalKeyedMs : cfg.intervalKeylessMs;
575
+ const maxRequests = apiKey ? cfg.maxRequestsKeyed : cfg.maxRequestsKeyless;
576
+
577
+ let requests = 0; let throttled = false; let dropped = 0;
578
+ const failures = []; const fresh = new Map(); // key -> Map(cveId -> record)
579
+ const request = async (url) => { if (requests > 0) await sleep(interval); requests++; return getJson(fetchImpl, url, headers, cfg.timeoutMs, cfg.maxResponseBytes); };
580
+
581
+ for (const key of need) {
582
+ if (throttled) { failures.push(`${key}: not queried (the service signalled a rate limit)`); continue; }
583
+ if (requests >= maxRequests) { failures.push(`${key}: not queried (per-scan request cap of ${maxRequests} reached; a later scan will cover it)`); continue; }
584
+ const recs = new Map();
585
+ try {
586
+ let start = 0; let total = null; let seen = 0;
587
+ for (let page = 0; page < cfg.maxPages; page++) {
588
+ if (page > 0 && requests >= maxRequests) throw new Error('per-scan request cap reached part-way through this identity');
589
+ const url = `${NVD_BASE}?virtualMatchString=${encodeURIComponent(`cpe:2.3:*:${key}`)}&resultsPerPage=${cfg.pageSize}&startIndex=${start}`;
590
+ const data = await request(url);
591
+ 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');
592
+ 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`); }
593
+ else if (data.totalResults !== total) throw new Error('the result set changed while it was being read');
594
+ for (const v of data.vulnerabilities) {
595
+ const rec = nvdToOsv(v && v.cve, key);
596
+ if (rec) recs.set(rec.id, rec); else dropped++;
597
+ }
598
+ seen += data.vulnerabilities.length; start += data.vulnerabilities.length;
599
+ if (seen >= total || !data.vulnerabilities.length) break;
600
+ }
601
+ if (seen < total) throw new Error('the result set was not read to the end');
602
+ fresh.set(key, recs);
603
+ } catch (e) {
604
+ const http = e && e.http;
605
+ if (http === 403 || http === 429 || http === 503) throttled = true;
606
+ failures.push(`${key}: ${String((e && e.message) || e).slice(0, 100)}`);
607
+ }
608
+ }
609
+
610
+ const uncovered = need.filter((k) => !fresh.has(k));
611
+ 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 });
612
+
613
+ // merge: for each fully read identity, replace what the cache held for it and keep everything else
614
+ const records = new Map([...cur.records].map(([id, r]) => [id, { ...r, affected: [...(r.affected || [])] }]));
615
+ for (const [key, recs] of fresh) {
616
+ 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); }
617
+ for (const [id, rec] of recs) {
618
+ const have = records.get(id);
619
+ if (have) have.affected.push(...rec.affected); else records.set(id, rec);
620
+ }
621
+ }
622
+ 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 });
623
+ const covered = { ...cur.covered };
624
+ const iso = new Date(now).toISOString();
625
+ for (const k of fresh.keys()) covered[k] = iso;
626
+
627
+ const sorted = [...records.values()].sort((a, b) => (a.id < b.id ? -1 : 1));
628
+ const snap = {
629
+ schema: SNAPSHOT_SCHEMA, source: 'nvd-cve-2.0', generatedAt: iso, records: sorted,
630
+ recordHashes: Object.fromEntries(sorted.map((r) => [r.id, nix_advisory_feed_sha256(JSON.stringify(r))])),
631
+ covered: Object.fromEntries(Object.entries(covered).sort(([a], [b]) => (a < b ? -1 : 1))),
632
+ };
633
+ try {
634
+ const dir = opts.dir || (0,trusted_inputs/* operatorConfigDir */.Us)(env);
635
+ (0,external_node_fs_.mkdirSync)(dir, { recursive: true, mode: 0o700 });
636
+ const tmp = `${path}.${process.pid}.tmp`;
637
+ (0,external_node_fs_.writeFileSync)(tmp, JSON.stringify(snap), { mode: 0o600 });
638
+ try { (0,external_node_fs_.chmodSync)(tmp, 0o600); } catch { /* best effort on filesystems without modes */ }
639
+ (0,external_node_fs_.renameSync)(tmp, path);
640
+ } catch (e) {
641
+ return done({ status: 'failed', detail: `the snapshot could not be written: ${e.code || e.message}`, identities: wanted.length, requested: need.length, uncovered });
642
+ }
643
+ return done({
644
+ status: uncovered.length ? 'partial' : 'refreshed',
645
+ 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}`,
646
+ identities: wanted.length, requested: need.length, requests, records: records.size, dropped, uncovered, failures: failures.slice(0, 10), throttled, path, cacheProblem: cur.problem,
647
+ });
648
+ }
649
+
368
650
  ;// CONCATENATED MODULE: ./src/language/nix-sca.js
369
651
  // Patch-aware Nix vulnerability matching and reachability (NIX-009).
370
652
  //
@@ -390,6 +672,8 @@ var nix_parser = __webpack_require__(54352);
390
672
 
391
673
 
392
674
 
675
+
676
+
393
677
  const NIX_SCA_VERSION = 'nix-sca/1';
394
678
  const HEX40 = /^[0-9a-f]{40}$/i;
395
679
  const STORE_HASH = /^[0-9a-df-np-sv-z]{32}$/;
@@ -427,16 +711,18 @@ function compareUpstream(a, b) {
427
711
  // ── advisories ───────────────────────────────────────────────────────────────
428
712
  const sha = (s) => String(s || '').toLowerCase().replace(/^sha256[-:]/, '');
429
713
  function eventsToRanges(events) {
430
- const out = []; let lo = null;
714
+ // `introduced_excluding` is not an OSV event: it carries an exclusive start bound (the NVD live feed has them), so 8.0 is not in "after 8.0".
715
+ const out = []; let lo = null; let loExc = false;
431
716
  for (const e of events || []) {
432
- if ('introduced' in e) lo = e.introduced === '0' ? null : e.introduced;
433
- else if ('fixed' in e) { out.push({ lo, hi: e.fixed, hiInc: false }); lo = null; }
434
- else if ('last_affected' in e) { out.push({ lo, hi: e.last_affected, hiInc: true }); lo = null; }
717
+ if ('introduced' in e) { lo = e.introduced === '0' ? null : e.introduced; loExc = false; }
718
+ else if ('introduced_excluding' in e) { lo = e.introduced_excluding; loExc = true; }
719
+ else if ('fixed' in e) { out.push({ lo, loExc, hi: e.fixed, hiInc: false }); lo = null; loExc = false; }
720
+ else if ('last_affected' in e) { out.push({ lo, loExc, hi: e.last_affected, hiInc: true }); lo = null; loExc = false; }
435
721
  }
436
- if (events && events.some((e) => 'introduced' in e) && (lo !== null || !out.length || !events.some((e) => 'fixed' in e || 'last_affected' in e))) out.push({ lo, hi: null, hiInc: false });
722
+ if (events && events.some((e) => 'introduced' in e || 'introduced_excluding' in e) && (lo !== null || !out.length || !events.some((e) => 'fixed' in e || 'last_affected' in e))) out.push({ lo, loExc, hi: null, hiInc: false });
437
723
  return out;
438
724
  }
439
- const inRange = (v, r) => (r.lo === null || compareUpstream(v, r.lo) >= 0) && (r.hi === null || (r.hiInc ? compareUpstream(v, r.hi) <= 0 : compareUpstream(v, r.hi) < 0));
725
+ const inRange = (v, r) => (r.lo === null || (r.loExc ? compareUpstream(v, r.lo) > 0 : compareUpstream(v, r.lo) >= 0)) && (r.hi === null || (r.hiInc ? compareUpstream(v, r.hi) <= 0 : compareUpstream(v, r.hi) < 0));
440
726
 
441
727
  /** Normalize a non-Hackage OSV-style record into what Nix matching needs. */
442
728
  function normalizeGenericAdvisory(rec) {
@@ -454,12 +740,15 @@ function normalizeGenericAdvisory(rec) {
454
740
  fixPatches: ((a.database_specific && a.database_specific.fix_patches) || (rec.database_specific && rec.database_specific.fix_patches) || []).map((p) => ({ name: p.name || null, sha256: p.sha256 ? sha(p.sha256) : null })),
455
741
  });
456
742
  }
457
- return { id: rec.id, ids: [rec.id, ...aliases], cves: [rec.id, ...aliases].filter((x) => /^CVE-/.test(x)), summary: rec.summary || '', withdrawn: rec.withdrawn || null, published: rec.published || null, modified: rec.modified || null, references: (rec.references || []).map((r) => r.url).filter(Boolean), affected };
743
+ return { id: rec.id, severityInfo: (0,cvss/* advisorySeverity */.Hp)(rec), ids: [rec.id, ...aliases], cves: [rec.id, ...aliases].filter((x) => /^CVE-/.test(x)), summary: rec.summary || '', withdrawn: rec.withdrawn || null, published: rec.published || null, modified: rec.modified || null, references: (rec.references || []).map((r) => r.url).filter(Boolean), affected };
458
744
  }
459
745
 
460
746
  class NixAdvisoryData {
461
- constructor({ records = [], hackage = null, source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30 } = {}) {
462
- this.source = source; this.generatedAt = generatedAt; this.maxAgeDays = maxAgeDays;
747
+ constructor({ records = [], hackage = null, source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, covered = null } = {}) {
748
+ this.source = source; this.generatedAt = generatedAt; this.maxAgeDays = maxAgeDays; this.now = now;
749
+ // `covered` is {'vendor:product': ISO time that CPE identity was last fully read from the live feed}. null means the snapshot makes no
750
+ // per-identity claim (a hand-built or operator-pinned snapshot) and is matched exactly as it always was.
751
+ this.covered = covered && typeof covered === 'object' ? covered : null;
463
752
  this.records = records.map(normalizeGenericAdvisory).filter(Boolean);
464
753
  this.hackage = hackage; // an AdvisoryDb (HS-009) for wrapped Haskell packages
465
754
  const gen = generatedAt ? Date.parse(generatedAt) : NaN;
@@ -467,6 +756,13 @@ class NixAdvisoryData {
467
756
  this.stale = this.ageDays === null ? true : this.ageDays > maxAgeDays;
468
757
  this.feed = { source, generatedAt, status: this.stale ? 'stale-cache' : 'current', records: this.records.length };
469
758
  }
759
+ /** 'covered' | 'uncovered' | 'stale' for a CPE identity ('vendor:product'); always 'covered' when the snapshot makes no per-identity claim. */
760
+ cpeCoverage(key) {
761
+ if (!this.covered) return 'covered';
762
+ const t = Date.parse(this.covered[key]);
763
+ if (!Number.isFinite(t)) return 'uncovered';
764
+ return (this.now - t) / 86400000 > this.maxAgeDays ? 'stale' : 'covered';
765
+ }
470
766
  }
471
767
 
472
768
  // ── upstream identity ────────────────────────────────────────────────────────
@@ -493,7 +789,7 @@ function upstreamIdentity(node, drvEnv, meta, overlay) {
493
789
  const ids = meta && meta.identifiers ? meta.identifiers : null;
494
790
  if (ids && typeof ids.purl === 'string') cands.push({ purl: ids.purl, authority: 'explicit', basis: 'meta.identifiers.purl' });
495
791
  const cpes = ids ? [].concat(ids.cpe || [], ids.possibleCPEs || [], ids.v1 && ids.v1.cpeParts ? [ids.v1.cpeParts] : []).filter(Boolean) : [];
496
- const cpeStrings = cpes.map((c) => (typeof c === 'string' ? c : (c.vendor && c.product ? `${c.vendor}:${c.product}` : null))).filter(Boolean);
792
+ const cpeStrings = cpes.map(cpeKey).filter(Boolean); // 'vendor:product' from a full CPE string or a {vendor, product} part; malformed values are dropped
497
793
  const uniqueCpe = [...new Set(cpeStrings)];
498
794
  if (uniqueCpe.length === 1 && ids && ids.cpe && !ids.possibleCPEs) cands.push({ cpe: uniqueCpe[0], authority: 'explicit', basis: 'meta.identifiers.cpe' });
499
795
  else for (const c of uniqueCpe) cands.push({ cpe: c, authority: 'candidate', basis: uniqueCpe.length > 1 ? 'one of several possible CPEs' : 'possible CPE' });
@@ -507,6 +803,32 @@ function upstreamIdentity(node, drvEnv, meta, overlay) {
507
803
  return { ...base, candidates: uniq, ambiguous: distinctTargets.size > 1 || (strong.length === 0 && uniq.length > 1) || uniq.some((c) => c.authority === 'candidate'), authority: strong.length && distinctTargets.size === 1 && !uniq.some((c) => c.authority === 'candidate') ? strong[0].authority : (uniq[0] ? 'name-only' : 'none'), haskell: !!isHs };
508
804
  }
509
805
 
806
+ /**
807
+ * The upstream identities of every dependency in a closure, in the form the matcher will use, so a pre-scan step can ask a live feed
808
+ * about exactly what the matcher will look up. `cpes` holds 'vendor:product' keys (explicit and candidate); a component with none is
809
+ * listed in `withoutCpe` so the caller can say what a CPE-keyed feed cannot cover.
810
+ */
811
+ function closureIdentities({ closure, drvEnv = {}, meta = {}, overlays = {} } = {}) {
812
+ const nodes = (closure && closure.nodes) || [];
813
+ const roots = new Set((closure && closure.roots) || []);
814
+ const subjects = new Map();
815
+ for (const n of nodes) {
816
+ if (n.kind !== 'output' || roots.has(n.id)) continue;
817
+ const key = n.deriver || n.id;
818
+ if (!subjects.has(key)) subjects.set(key, { drv: n.deriver || null, node: n });
819
+ const s = subjects.get(key);
820
+ if (n.outputName === 'out' || (s.node.outputName !== 'out' && n.scopes.includes('runtime'))) s.node = n;
821
+ }
822
+ const cpes = new Set(); const withoutCpe = [];
823
+ for (const s of subjects.values()) {
824
+ const node = s.node;
825
+ const ident = upstreamIdentity(node, (s.drv && drvEnv[s.drv]) || null, meta[node.pname] || meta[node.storeName] || null, overlays[node.pname] || null);
826
+ const mine = ident.candidates.filter((c) => c.cpe).map((c) => c.cpe);
827
+ if (mine.length) mine.forEach((k) => cpes.add(k)); else if (node.pname) withoutCpe.push(node.pname);
828
+ }
829
+ return { cpes: [...cpes].sort(), withoutCpe: [...new Set(withoutCpe)].sort() };
830
+ }
831
+
510
832
  // ── overlays and patches ─────────────────────────────────────────────────────
511
833
  const segName = (s) => (s && s.kind === 'static' ? s.name : null);
512
834
  const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
@@ -665,11 +987,12 @@ function matchNixVulnerabilities(opts = {}) {
665
987
  const st = decide(r, pe, ident);
666
988
  matched = true;
667
989
  if (haskellUsage) { const rr = (0,haskell_sca/* reachability */.oY)(node.pname, haskellUsage.imports, haskellUsage.callees, symbols[adv.id] || null); tiers.reachability = { import: rr.import, function: rr.function, basis: rr.reason }; }
668
- findings.push(finding({ ...base, tiers: { ...tiers } }, { id: adv.canonicalId, ids: adv.ids, summary: adv.summary, cves: adv.cveAliases }, st.status, st.reason, { source: data.hackage.source, ecosystem: 'Hackage', fixedIn: aff.fixedIn, patchEvidence: pe }, { kev, epss }));
990
+ findings.push(finding({ ...base, tiers: { ...tiers } }, { id: adv.canonicalId, ids: adv.ids, summary: adv.summary, cves: adv.cveAliases, severityInfo: adv.severityInfo }, st.status, st.reason, { source: data.hackage.source, ecosystem: 'Hackage', fixedIn: aff.fixedIn, patchEvidence: pe }, { kev, epss }));
669
991
  }
670
992
  }
671
993
  // generic records by PURL / CPE; only authoritative identities are matched, candidates are leads
672
994
  for (const adv of data.records) {
995
+ if (adv.withdrawn) continue;
673
996
  for (const aff of adv.affected) {
674
997
  const target = ident.candidates.find((c) => (c.purl && aff.purl && c.purl === aff.purl) || (c.cpe && aff.cpe && c.cpe === aff.cpe) || (c.name && aff.name && !aff.purl && !aff.cpe && c.name === aff.name && c.authority === 'name-only') || (c.ecosystem === 'Hackage' && aff.ecosystem === 'Hackage' && c.name === aff.name));
675
998
  if (!target) continue;
@@ -688,7 +1011,21 @@ function matchNixVulnerabilities(opts = {}) {
688
1011
  findings.push(finding({ ...base, tiers: { ...tiers } }, adv, st.status, st.reason, { source: data.source, ecosystem: aff.ecosystem, fixedIn: aff.fixedIn, patchEvidence: pe, identityBasis: target.basis, identityAuthority: target.authority }, { kev, epss }));
689
1012
  }
690
1013
  }
1014
+ // A feed that records coverage per CPE identity can only vouch for the identities it actually read. Without a covered, explicit,
1015
+ // unambiguous CPE identity, "no match" says nothing, so it is unknown (never not-affected).
1016
+ let covGap = null;
1017
+ if (!hackageGap && data.covered) {
1018
+ const cpeCands = ident.candidates.filter((c) => c.cpe);
1019
+ const firm = !ident.ambiguous ? cpeCands.find((c) => c.authority === 'explicit') : null;
1020
+ if (firm) {
1021
+ const cv = data.cpeCoverage(firm.cpe);
1022
+ if (cv === 'covered') mapped = true;
1023
+ else covGap = cv === 'stale' ? `the live advisory feed last read ${firm.cpe} longer ago than its age limit: the absence of a match is not a clean result` : `the live advisory feed never read ${firm.cpe} (it was not queried or the query did not complete): the absence of a match is not a clean result`;
1024
+ } else if (!matched && !cpeCands.length) covGap = 'the live advisory feed is keyed by CPE and this component declares no CPE identity (a source URL or purl alone is not something it can look up): the absence of a match is not a clean result';
1025
+ }
691
1026
  if (matched) summary.matched++;
1027
+ if (covGap) { if (!matched) summary.unmapped++; push({ status: 'unknown', reason: covGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
1028
+ else if (matched) { /* findings above are the result */ }
692
1029
  else if (hackageGap) { summary.unmapped++; push({ status: 'unknown', reason: hackageGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
693
1030
  else if (mapped) { summary.mappedNoMatch++; push({ status: 'not-affected', reason: 'mapped to an upstream identity and no advisory in the supplied feed matches this version' }, { identityMapped: true }); }
694
1031
  else { summary.unmapped++; push({ status: 'unknown', reason: ident.ambiguous ? 'the upstream identity is ambiguous: no verdict' : 'the upstream identity could not be mapped to anything an advisory is keyed by' }, { identityMapped: false }); }
@@ -708,10 +1045,14 @@ function finding(base, adv, status, reason, extra, { kev, epss }) {
708
1045
  const kevHit = cves.length ? (kev ? cves.some((c) => kev.has(c)) : 'unknown') : 'not-applicable';
709
1046
  const ep = cves.map((c) => epss && epss[c]).filter((x) => typeof x === 'number');
710
1047
  const level = { affected: 'high', 'possibly-affected': 'medium', candidate: 'low', 'backported-verified': 'info', fixed: 'info', unknown: 'low', 'not-affected': 'info' }[status] || 'low';
1048
+ // The advisory's own rating applies only where the package is judged vulnerable; the other statuses keep their fixed,
1049
+ // lower levels (a verified backport must never be raised by the upstream score).
1050
+ const sev = adv.severityInfo || { level: null, score: null, basis: cvss/* NO_RATING_BASIS */.Ts };
1051
+ const rated = !!sev.level && (status === 'affected' || status === 'possibly-affected');
711
1052
  return {
712
1053
  type: 'vulnerable_dep', ecosystem: 'nix', language: 'nix', capability: 'sca', analysisKind: 'application', evidenceKind: 'closure',
713
1054
  name: base.name, version: base.version, osvId: adv.id, ids: adv.ids || [adv.id], cveAliases: cves, summary: adv.summary || '',
714
- status, matchStatus: status, matchReason: reason, severity: level, severityBasis: 'the advisory carries no severity rating',
1055
+ status, matchStatus: status, matchReason: reason, severity: rated ? sev.level : level, severityBasis: rated ? sev.basis : sev.level ? `the match status "${status}" fixes this level; the advisory rating (${sev.basis}) is not applied` : sev.basis, ...(rated ? { severityScore: sev.score } : {}),
715
1056
  nixBuild: base.nixBuild, identity: base.identity, tiers: base.tiers, feed: base.feed,
716
1057
  kev: kevHit, epss: ep.length ? Math.max(...ep) : 'unknown',
717
1058
  fixedIn: extra.fixedIn || [], patchEvidence: extra.patchEvidence || { verified: null, claims: [] }, dataSource: { feed: extra.source || base.feed.source, ecosystem: extra.ecosystem || null, identityBasis: extra.identityBasis || null, identityAuthority: extra.identityAuthority || null, feedStatus: base.feed.status },
@@ -731,16 +1072,14 @@ function licenseReport(nodes, meta, policy) {
731
1072
 
732
1073
 
733
1074
 
734
- // EXTERNAL MODULE: ./src/language/haskell-supply.js + 1 modules
735
- var haskell_supply = __webpack_require__(43436);
1075
+ // EXTERNAL MODULE: ./src/language/haskell-supply.js
1076
+ var haskell_supply = __webpack_require__(86349);
736
1077
  // EXTERNAL MODULE: external "node:child_process"
737
1078
  var external_node_child_process_ = __webpack_require__(31421);
738
1079
  // EXTERNAL MODULE: external "node:net"
739
1080
  var external_node_net_ = __webpack_require__(77030);
740
1081
  // EXTERNAL MODULE: external "node:os"
741
1082
  var external_node_os_ = __webpack_require__(48161);
742
- // EXTERNAL MODULE: external "node:path"
743
- var external_node_path_ = __webpack_require__(76760);
744
1083
  ;// CONCATENATED MODULE: ./src/language/nix-eval-isolation.js
745
1084
  // Isolated, opt-in Nix evaluation (NIX-011).
746
1085
  //
@@ -1040,6 +1379,7 @@ function mergeEvaluationHealth(scanHealth, result) {
1040
1379
 
1041
1380
 
1042
1381
 
1382
+
1043
1383
  const RESOLVED_PASS_VERSION = 'resolved-pass/1';
1044
1384
 
1045
1385
  const PLAN = /(?:^|\/)dist-newstyle\/cache\/plan\.json$/;
@@ -1160,10 +1500,58 @@ function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
1160
1500
  const sel = (0,trusted_inputs/* resolveOperatorSnapshot */.Rn)({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
1161
1501
  if (!sel.path) return { data: null, reason: `no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES, or place nix-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? (0,trusted_inputs/* IGNORED_NOTE */.cY)('nix-advisories.json') : ''}` };
1162
1502
  let snap; try { snap = JSON.parse((0,external_node_fs_.readFileSync)(sel.path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
1503
+ if (snap && snap.schema === SNAPSHOT_SCHEMA) {
1504
+ // A snapshot the live feed wrote: its records must match their hashes, and it carries which CPE identities it actually read.
1505
+ const live = loadLiveSnapshot(snap);
1506
+ if (!live.ok) return { data: null, reason: `the live Nix advisory snapshot was refused: ${live.reason}` };
1507
+ return { data: new NixAdvisoryData({ records: live.records, hackage: hackageDb, source: sel.source === 'env' ? 'pinned-snapshot' : 'nvd-live-feed', generatedAt: live.generatedAt, covered: live.covered }), reason: null };
1508
+ }
1163
1509
  const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
1164
1510
  return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
1165
1511
  }
1166
1512
 
1513
+ /**
1514
+ * Package metadata for the closure (meta.identifiers, knownVulnerabilities, license), from a file the OPERATOR names in
1515
+ * AGENTIC_SECURITY_NIX_META: either `{pname: meta}` or the `nix-env -qa --meta --json` shape (`{attr: {pname, meta}}`). It is never
1516
+ * read from the scanned project, because metadata decides which upstream identity a component is matched under.
1517
+ */
1518
+ function configuredNixMeta(env = process.env) {
1519
+ const p = env.AGENTIC_SECURITY_NIX_META;
1520
+ if (!p) return { meta: {}, reason: null };
1521
+ let raw;
1522
+ try { const text = (0,external_node_fs_.readFileSync)(p, 'utf8'); if (text.length > (32 << 20)) return { meta: {}, reason: 'the Nix metadata file is larger than 32 MB and was not read' }; raw = JSON.parse(text); } catch (e) { return { meta: {}, reason: `the Nix metadata file is unreadable: ${e.code || e.message}` }; }
1523
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { meta: {}, reason: 'the Nix metadata file is not a JSON object' };
1524
+ const meta = {};
1525
+ for (const [k, v] of Object.entries(raw)) {
1526
+ if (!v || typeof v !== 'object') continue;
1527
+ const m = v.meta && typeof v.meta === 'object' ? v.meta : v;
1528
+ const name = typeof v.pname === 'string' && v.pname ? v.pname : k.split('.').pop();
1529
+ if (/^[\w.+-]{1,100}$/.test(name) && !Object.prototype.hasOwnProperty.call(meta, name)) meta[name] = m;
1530
+ }
1531
+ return { meta, reason: null };
1532
+ }
1533
+
1534
+ /**
1535
+ * The async step before a scan's synchronous passes: when the live Nix feed is enabled, make sure the operator snapshot covers every
1536
+ * CPE identity the imported closure declares. Does nothing, and costs nothing, unless the feed is enabled. Never throws.
1537
+ */
1538
+ async function prefetchNixAdvisoryFeed(files, { env = process.env, ...opts } = {}) {
1539
+ if (!liveFeedEnabled(env)) return null;
1540
+ try {
1541
+ const c = nixClosureOf(files, opts.now ? { now: opts.now } : {});
1542
+ if (!c || (c.refused && c.refused.length)) return null;
1543
+ const { cpes } = closureIdentities({ closure: c.closure, drvEnv: c.drvEnv, meta: configuredNixMeta(env).meta, overlays: overlayEvidence(files) });
1544
+ if (!cpes.length) return null;
1545
+ return await refreshNixAdvisories(cpes, { env, ...opts });
1546
+ } catch (e) { return { status: 'failed', detail: `the feed step failed: ${String((e && e.message) || e).slice(0, 120)}` }; }
1547
+ }
1548
+
1549
+ function nixLiveFeedNote(env) {
1550
+ const r = getLastRefresh();
1551
+ if (r && r.status !== 'disabled') return ` Live feed: ${r.status}, ${r.detail}.`;
1552
+ return env[FEED_ENV] === '1' ? '' : ` To fetch advisories for the closure's upstream software from the NVD instead, set ${FEED_ENV}=1 (network, opt-in).`;
1553
+ }
1554
+
1167
1555
  /** Closure statuses and findings, with every disclosure the import and the feed produced. */
1168
1556
  function analyzeNixClosure(files, opts = {}) {
1169
1557
  return memoized(files, 'nix-analysis', () => _analyzeNixClosure(files, opts));
@@ -1173,16 +1561,21 @@ function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = D
1173
1561
  if (!c) return null;
1174
1562
  const hackage = (0,haskell_supply.configuredAdvisoryDb)(scanRoot, env).db;
1175
1563
  const adv = configuredNixAdvisories(scanRoot, env, hackage);
1564
+ if (!adv.data) adv.reason = `${adv.reason}${nixLiveFeedNote(env)}`;
1565
+ const nm = configuredNixMeta(env);
1176
1566
  let usage = null; try { usage = (0,haskell_supply/* collectUsage */.c$)(files); } catch { usage = null; }
1177
1567
  let matched = null;
1178
- if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
1568
+ if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, meta: nm.meta, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
1179
1569
  const gaps = [];
1180
1570
  if (c.trust.includes('project-supplied') && !c.trust.includes('operator')) gaps.push({ kind: 'closure-project-supplied', detail: 'the closure export is a file in the scanned project, bound to its flake.lock but not signed: whoever controls the project controls its contents, so the ABSENCE of a finding for a component it lists is not evidence the real build is free of it (provide the export through AGENTIC_SECURITY_NIX_EXPORT, signed with AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, to have it treated as operator evidence)' });
1181
1571
  for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
1182
1572
  for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
1183
1573
  for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
1184
1574
  if (!adv.data) gaps.push({ kind: 'closure-advisory-feed-unavailable', detail: `${adv.reason}. The closure's components were NOT checked against any advisory: the absence of findings is not a clean result.` });
1185
- else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });
1575
+ if (nm.reason) gaps.push({ kind: 'closure-meta-unreadable', detail: nm.reason });
1576
+ const notCovered = ((matched && matched.statuses) || []).filter((x) => x.feedCoverage === 'incomplete');
1577
+ if (adv.data && notCovered.length) gaps.push({ kind: 'closure-advisory-feed-incomplete', detail: `${notCovered.length} component(s) were not covered by the live advisory feed (${[...new Set(notCovered.map((x) => x.name))].slice(0, 5).join(', ')}${notCovered.length > 5 ? ', ...' : ''}): their status is unknown, and the absence of findings for them is not a clean result.` });
1578
+ if (!adv.data) { /* reported above */ } else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });
1186
1579
  const findings = ((matched && matched.findings) || []).filter((f) => ACTIONABLE.has(f.status)).map((f) => ({ ...f, file: c.sources[0], line: 1 }));
1187
1580
  return { status: c.closure.status, claims: c.closure.claims, sources: c.sources, summary: matched ? matched.summary : null, feed: matched ? matched.feed : null, statuses: matched ? matched.statuses : [], findings, gaps, licenses: matched ? matched.licenses : null, closure: c.closure };
1188
1581
  }
@@ -1726,7 +1726,9 @@ const synthesize_fix = {
1726
1726
  const proj = lc.loadLanguageProject(ctx.sessionRoot);
1727
1727
  const prev = await lc.languageFixPreview(f, proj.files);
1728
1728
  languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
1729
- if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
1729
+ // apply_fix writes ONE file; a fix that spans several files must go through the CLI lifecycle (atomic, grouped undo).
1730
+ if (prev.ok && prev.edits) languageFix.multiFile = prev.edits.map((e) => e.file);
1731
+ else if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
1730
1732
  }
1731
1733
  } catch { /* best-effort: the preview is advisory */ }
1732
1734
  }