@clear-capabilities/agentic-security-scanner 0.158.0 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/3531.index.js +1 -1
- package/dist/4171.index.js +4 -4
- package/dist/{8551.index.js → 7033.index.js} +415 -22
- package/dist/9560.index.js +3 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +11 -6
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +48 -1
- package/src/language/assurance.js +21 -4
- package/src/language/context.js +2 -0
- package/src/language/cvss.js +91 -0
- package/src/language/fix-lifecycle.js +114 -21
- package/src/language/haskell-adapter.js +4 -3
- package/src/language/haskell-advisory-feed.js +15 -0
- package/src/language/haskell-boundaries.js +220 -0
- package/src/language/haskell-cpp.js +460 -0
- package/src/language/haskell-ir.js +6 -3
- package/src/language/haskell-models.js +4 -3
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +10 -6
- package/src/language/haskell-supply.js +42 -8
- package/src/language/haskell-web.js +128 -23
- package/src/language/nix-advisory-feed.js +275 -0
- package/src/language/nix-fix.js +162 -24
- package/src/language/nix-ir.js +16 -12
- package/src/language/nix-sca.js +71 -12
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-module-resolver.js +346 -51
- package/src/language/resolved-pass.js +57 -3
- package/src/lsp/server.js +5 -2
- package/src/mcp/tools.js +3 -1
- package/src/posture/fix-history.js +30 -3
- package/src/report/index.js +4 -1
- package/dist/1310.index.js +0 -3161
- package/dist/2376.index.js +0 -3151
- package/dist/3526.index.js +0 -1019
- package/dist/7310.index.js +0 -520
- package/dist/8520.index.js +0 -13
- package/dist/9091.index.js +0 -3135
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export const id =
|
|
2
|
-
export const ids = [
|
|
1
|
+
export const id = 7033;
|
|
2
|
+
export const ids = [7033];
|
|
3
3
|
export const modules = {
|
|
4
4
|
|
|
5
|
-
/***/
|
|
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
|
-
|
|
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 ('
|
|
434
|
-
else if ('
|
|
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((
|
|
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:
|
|
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
|
|
735
|
-
var haskell_supply = __webpack_require__(
|
|
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
|
-
|
|
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
|
}
|
package/dist/9560.index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|