@clear-capabilities/agentic-security-scanner 0.172.1 → 0.173.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 +15 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +28 -3
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +272 -20
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/util/string-memo.js +38 -0
|
@@ -0,0 +1,1631 @@
|
|
|
1
|
+
export const id = 208;
|
|
2
|
+
export const ids = [208];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 30208:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// EXPORTS
|
|
10
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
+
analyzeNixClosure: () => (/* binding */ analyzeNixClosure),
|
|
12
|
+
nixClosureOf: () => (/* binding */ nixClosureOf),
|
|
13
|
+
nixHaskellPackageNames: () => (/* binding */ nixHaskellPackageNames),
|
|
14
|
+
prefetchNixAdvisoryFeed: () => (/* binding */ prefetchNixAdvisoryFeed),
|
|
15
|
+
resolvedHackageComponents: () => (/* binding */ resolvedHackageComponents),
|
|
16
|
+
resolvedHaskellGraph: () => (/* binding */ resolvedHaskellGraph),
|
|
17
|
+
runSelectedNixEval: () => (/* binding */ runSelectedNixEval)
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// UNUSED EXPORTS: RESOLVED_PASS_VERSION, configuredNixAdvisories, configuredNixMeta, mergeEvaluationHealth
|
|
21
|
+
|
|
22
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
23
|
+
var external_node_fs_ = __webpack_require__(73024);
|
|
24
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
25
|
+
var external_node_crypto_ = __webpack_require__(77598);
|
|
26
|
+
// EXTERNAL MODULE: ./src/language/trusted-inputs.js
|
|
27
|
+
var trusted_inputs = __webpack_require__(31531);
|
|
28
|
+
// EXTERNAL MODULE: ./src/language/haskell-manifests.js
|
|
29
|
+
var haskell_manifests = __webpack_require__(60377);
|
|
30
|
+
// EXTERNAL MODULE: ./src/language/haskell-resolved-graph.js
|
|
31
|
+
var haskell_resolved_graph = __webpack_require__(61906);
|
|
32
|
+
;// CONCATENATED MODULE: ./src/language/nix-closure.js
|
|
33
|
+
// Resolved derivation and closure inventory (NIX-008).
|
|
34
|
+
//
|
|
35
|
+
// Reads EXPLICITLY SUPPLIED, target-specific exports produced by supported Nix tools; it never runs nix,
|
|
36
|
+
// never discovers an executable and never touches the filesystem (this module imports no fs): store-looking
|
|
37
|
+
// paths are only strings to be validated and related to each other.
|
|
38
|
+
//
|
|
39
|
+
// Supported schemas (all documented in docs/guides/nix.md):
|
|
40
|
+
// nix-path-info-json `nix path-info --json --recursive <installable>`: the object form (keys are store
|
|
41
|
+
// paths, Nix >= 2.19) and the older array form ([{path, references, deriver, ...}])
|
|
42
|
+
// nix-derivation-show-json `nix derivation show --recursive <installable>`: object keyed by .drv path with
|
|
43
|
+
// outputs, inputSrcs, inputDrvs, env; also the wrapped {derivations, version} form
|
|
44
|
+
// whose inputs live under inputs.drvs / inputs.srcs and whose paths omit /nix/store/
|
|
45
|
+
// nix-store-query-text `nix-store --query --requisites <path>`: one store path per line (OUTPUT-ONLY
|
|
46
|
+
// identity: no deriver, hash or version, so it can never support an exact claim)
|
|
47
|
+
//
|
|
48
|
+
// Two different graphs come out and are never conflated: the RUNTIME closure (references of the target's
|
|
49
|
+
// output paths) and the BUILD graph (inputDrvs of its derivation). A node's scopes say which it is in.
|
|
50
|
+
// An exact claim is made only when every condition holds; each failed condition is a disclosure.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
const NIX_CLOSURE_VERSION = 'nix-closure/1';
|
|
55
|
+
const CLOSURE_BUDGETS = Object.freeze({ maxBytes: 64 * 1024 * 1024, maxNodes: 200_000, maxEdges: 1_000_000, maxEnvBytes: 1 << 20, maxExports: 16 });
|
|
56
|
+
const SUPPORTED_TOOLS = new Set(['nix', 'nix-store']);
|
|
57
|
+
const SCHEMA_COMMANDS = {
|
|
58
|
+
'nix-path-info-json': /^nix(?:\s+--\S+)*\s+path-info\b.*--json\b|^nix(?:\s+--\S+)*\s+path-info\b.*--recursive\b/,
|
|
59
|
+
'nix-derivation-show-json': /^nix(?:\s+--\S+)*\s+(?:derivation\s+show|show-derivation)\b/,
|
|
60
|
+
'nix-store-query-text': /^nix-store\b.*(?:--query|-q)\b.*(?:--requisites|-R|--references|--tree)\b|^nix-store\s+-qR\b/,
|
|
61
|
+
};
|
|
62
|
+
const STORE = '/nix/store/';
|
|
63
|
+
const BASE32 = '0123456789abcdfghijklmnpqrsvwxyz';
|
|
64
|
+
const STORE_PATH = new RegExp(`^/nix/store/([${BASE32}]{32})-([^/\\x00\\s]+)$`);
|
|
65
|
+
const OUTPUT_SUFFIX = /-(?:dev|bin|lib|out|man|doc|info|devdoc|debug|static|dist|fakeroot|headers|include|terminfo)$/;
|
|
66
|
+
const ROLE_OF_ENV = [
|
|
67
|
+
['nativeCheckInputs', 'test'], ['checkInputs', 'test'], ['installCheckInputs', 'test'],
|
|
68
|
+
['nativeBuildInputs', 'build'], ['depsBuildBuild', 'build'], ['depsBuildTarget', 'build'], ['depsBuildBuildPropagated', 'build'], ['propagatedNativeBuildInputs', 'build'],
|
|
69
|
+
['buildInputs', 'build+runtime'], ['propagatedBuildInputs', 'build+runtime'], ['depsHostHost', 'build+runtime'], ['depsTargetTarget', 'build+runtime'], ['depsHostHostPropagated', 'build+runtime'],
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/** Validate and split a store path. Returns null for anything that is not exactly /nix/store/<hash>-<name>. */
|
|
73
|
+
function parseStorePath(p) {
|
|
74
|
+
if (typeof p !== 'string' || p.length > 1024) return null;
|
|
75
|
+
const m = STORE_PATH.exec(p);
|
|
76
|
+
if (!m || m[2] === '.' || m[2] === '..' || m[2].includes('..')) return null;
|
|
77
|
+
return { path: p, hash: m[1], name: m[2] };
|
|
78
|
+
}
|
|
79
|
+
const normalizePath = (p) => (typeof p === 'string' && /^[0-9a-df-np-sv-z]{32}-/.test(p) ? STORE + p : p);
|
|
80
|
+
|
|
81
|
+
/** Heuristic only: nixpkgs naming is pname-version where the version starts at the first "-<digit>". */
|
|
82
|
+
function inferNameVersion(name, drvEnv) {
|
|
83
|
+
if (drvEnv && typeof drvEnv.pname === 'string' && typeof drvEnv.version === 'string') return { pname: drvEnv.pname, version: drvEnv.version, authority: 'derivation-env' };
|
|
84
|
+
if (drvEnv && typeof drvEnv.name === 'string') { const m = /^(.+?)-(\d.*)$/.exec(drvEnv.name); if (m) return { pname: m[1], version: m[2], authority: 'derivation-env' }; }
|
|
85
|
+
const base = String(name || '').replace(/\.drv$/, '').replace(OUTPUT_SUFFIX, '');
|
|
86
|
+
const m = /^(.+?)-(\d.*)$/.exec(base);
|
|
87
|
+
return m ? { pname: m[1], version: m[2], authority: 'inferred-from-store-path' } : { pname: base || null, version: null, authority: 'inferred-from-store-path' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function detectSchema(parsed, text) {
|
|
91
|
+
if (typeof text === 'string' && parsed === undefined) {
|
|
92
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
93
|
+
return lines.length && lines.every((l) => l.startsWith('/')) ? 'nix-store-query-text' : null;
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(parsed)) return parsed.every((x) => x && typeof x === 'object' && typeof x.path === 'string') ? 'nix-path-info-json' : null;
|
|
96
|
+
if (parsed && typeof parsed === 'object') {
|
|
97
|
+
if (parsed.derivations && typeof parsed.derivations === 'object') return 'nix-derivation-show-json';
|
|
98
|
+
const ks = Object.keys(parsed);
|
|
99
|
+
if (!ks.length) return null;
|
|
100
|
+
if (ks.every((k) => /\.drv$/.test(k))) return 'nix-derivation-show-json';
|
|
101
|
+
if (ks.every((k) => /^(?:\/nix\/store\/)?[0-9a-df-np-sv-z]{32}-/.test(k))) return 'nix-path-info-json';
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {{exports: Array<{schema?:string, text?:string, data?:any, provenance?:object, signature?:string}>,
|
|
110
|
+
* expected?: {system?:string, installable?:string, revision?:object, flakeLockSha256?:string, maxAgeDays?:number, inputsChangedAtMs?:number, roots?:string[], requireSigned?:boolean, publicKeyPem?:string},
|
|
111
|
+
* now?: number, budgets?: object}} opts
|
|
112
|
+
*/
|
|
113
|
+
function importNixClosure(opts = {}) {
|
|
114
|
+
const b = { ...CLOSURE_BUDGETS, ...(opts.budgets || {}) };
|
|
115
|
+
const now = opts.now ?? Date.now();
|
|
116
|
+
const expected = opts.expected || {};
|
|
117
|
+
const dis = []; const diag = [];
|
|
118
|
+
const add = (kind, detail, extra = {}) => dis.push({ kind, detail, ...extra });
|
|
119
|
+
const out = { version: NIX_CLOSURE_VERSION, status: 'ok', nodes: [], edges: [], disclosures: dis, diagnostics: diag, claims: null, provenance: [], roots: [], coverage: {} };
|
|
120
|
+
const exps = (opts.exports || []).slice(0, b.maxExports);
|
|
121
|
+
if ((opts.exports || []).length > b.maxExports) add('truncated', `only the first ${b.maxExports} exports were read`);
|
|
122
|
+
if (!exps.length) { add('no-export', 'no export was supplied: nothing about the closure is known'); out.status = 'empty'; out.claims = claims(false, false, dis); return out; }
|
|
123
|
+
|
|
124
|
+
// ── parse each export ──
|
|
125
|
+
const pathInfo = new Map(); // path -> {references[], deriver, narHash, narSize, valid}
|
|
126
|
+
const drvs = new Map(); // drv path -> entry
|
|
127
|
+
const queryPaths = new Set();
|
|
128
|
+
const provenance = [];
|
|
129
|
+
let truncated = false; let malformed = 0;
|
|
130
|
+
|
|
131
|
+
for (const [idx, ex] of exps.entries()) {
|
|
132
|
+
const label = `export[${idx}]`;
|
|
133
|
+
let parsed; let text = typeof ex.text === 'string' ? ex.text : null;
|
|
134
|
+
if (text !== null && text.length > b.maxBytes) { add('truncated', `${label} is ${text.length} bytes (limit ${b.maxBytes}); not read`); truncated = true; continue; }
|
|
135
|
+
if (ex.data !== undefined) parsed = ex.data;
|
|
136
|
+
else if (text !== null) { try { parsed = JSON.parse(text); } catch { parsed = undefined; } }
|
|
137
|
+
let schema = ex.schema || detectSchema(parsed, text);
|
|
138
|
+
if (!schema || !(schema in SCHEMA_COMMANDS)) { diag.push({ export: label, kind: 'unsupported-schema', detail: ex.schema ? `schema ${ex.schema} is not supported` : 'could not recognise the export format' }); malformed++; continue; }
|
|
139
|
+
const prov = assessProvenance(ex, schema, expected, now, label, add);
|
|
140
|
+
provenance.push({ export: label, schema, ...prov.summary });
|
|
141
|
+
if (schema === 'nix-store-query-text') {
|
|
142
|
+
const lines = String(text !== null ? text : '').split(/\r?\n/).filter(Boolean);
|
|
143
|
+
for (const l of lines) { const sp = parseStorePath(l.trim()); if (!sp) { diag.push({ export: label, kind: 'invalid-store-path', detail: `not a store path: ${l.slice(0, 80)}` }); malformed++; continue; } if (queryPaths.size < b.maxNodes) queryPaths.add(sp.path); else truncated = true; }
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (schema === 'nix-path-info-json') {
|
|
147
|
+
const entries = Array.isArray(parsed) ? parsed.map((e) => [e && e.path, e]) : Object.entries(parsed || {});
|
|
148
|
+
for (const [p, e] of entries) {
|
|
149
|
+
const sp = parseStorePath(normalizePath(p));
|
|
150
|
+
if (!sp) { diag.push({ export: label, kind: 'invalid-store-path', detail: `not a store path: ${String(p).slice(0, 80)}` }); malformed++; continue; }
|
|
151
|
+
if (e === null) { pathInfo.set(sp.path, { references: null, valid: false }); continue; } // invalid path in the store
|
|
152
|
+
if (!e || typeof e !== 'object') { diag.push({ export: label, kind: 'malformed-entry', detail: sp.path }); malformed++; continue; }
|
|
153
|
+
if (pathInfo.size >= b.maxNodes) { truncated = true; break; }
|
|
154
|
+
const refs = Array.isArray(e.references) ? e.references.map((r) => parseStorePath(normalizePath(r))).filter(Boolean).map((r) => r.path) : null;
|
|
155
|
+
if (Array.isArray(e.references) && refs.length !== e.references.length) { diag.push({ export: label, kind: 'invalid-store-path', detail: `${sp.path} has reference(s) that are not store paths` }); malformed++; }
|
|
156
|
+
pathInfo.set(sp.path, { references: refs, deriver: typeof e.deriver === 'string' && e.deriver ? normalizePath(e.deriver) : null, narHash: typeof e.narHash === 'string' ? e.narHash : null, narSize: Number.isFinite(e.narSize) ? e.narSize : null, valid: e.valid !== false, signatures: Array.isArray(e.signatures) ? e.signatures.length : 0 });
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// derivation show
|
|
161
|
+
const wrapped = parsed && parsed.derivations && typeof parsed.derivations === 'object';
|
|
162
|
+
const table = wrapped ? parsed.derivations : parsed;
|
|
163
|
+
for (const [dp0, e] of Object.entries(table || {})) {
|
|
164
|
+
const dp = normalizePath(dp0);
|
|
165
|
+
const sp = parseStorePath(dp);
|
|
166
|
+
if (!sp || !/\.drv$/.test(sp.name)) { diag.push({ export: label, kind: 'invalid-store-path', detail: `not a derivation path: ${String(dp0).slice(0, 80)}` }); malformed++; continue; }
|
|
167
|
+
if (!e || typeof e !== 'object') { diag.push({ export: label, kind: 'malformed-entry', detail: sp.path }); malformed++; continue; }
|
|
168
|
+
if (drvs.size >= b.maxNodes) { truncated = true; break; }
|
|
169
|
+
const outputs = {};
|
|
170
|
+
if (e.outputs && typeof e.outputs === 'object') for (const [n, o] of Object.entries(e.outputs)) { const p = o && o.path ? parseStorePath(normalizePath(o.path)) : null; outputs[n] = p ? p.path : null; }
|
|
171
|
+
const inputDrvs = {};
|
|
172
|
+
const rawIn = wrapped && e.inputs ? (e.inputs.drvs || {}) : (e.inputDrvs || {});
|
|
173
|
+
for (const [k, v] of Object.entries(rawIn)) { const p = parseStorePath(normalizePath(k)); if (!p) { diag.push({ export: label, kind: 'invalid-store-path', detail: `inputDrv key in ${sp.name}` }); malformed++; continue; } inputDrvs[p.path] = Array.isArray(v) ? v : (v && Array.isArray(v.outputs) ? v.outputs : []); }
|
|
174
|
+
const rawSrcs = wrapped && e.inputs ? (e.inputs.srcs || []) : (e.inputSrcs || []);
|
|
175
|
+
const inputSrcs = (Array.isArray(rawSrcs) ? rawSrcs : []).map((s) => parseStorePath(normalizePath(s))).filter(Boolean).map((s) => s.path);
|
|
176
|
+
let env = e.env && typeof e.env === 'object' ? e.env : null;
|
|
177
|
+
if (env && JSON.stringify(env).length > b.maxEnvBytes) { env = null; diag.push({ export: label, kind: 'env-too-large', detail: sp.name }); }
|
|
178
|
+
drvs.set(sp.path, { outputs, inputDrvs, inputSrcs, system: typeof e.system === 'string' ? e.system : null, builder: typeof e.builder === 'string' ? e.builder : null, env });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
out.provenance = provenance;
|
|
182
|
+
if (truncated) { add('truncated', `an export exceeded the node budget (${b.maxNodes}) or size limit; the inventory is partial`); }
|
|
183
|
+
if (malformed) add('malformed', `${malformed} entr${malformed === 1 ? 'y' : 'ies'} could not be used (see diagnostics)`);
|
|
184
|
+
|
|
185
|
+
// ── node table ──
|
|
186
|
+
const nodes = new Map();
|
|
187
|
+
const node = (id, kind) => { if (!nodes.has(id)) { const sp = parseStorePath(id); nodes.set(id, { id, kind, hash: sp ? sp.hash : null, storeName: sp ? sp.name : null, scopes: new Set(), outputs: [], system: null, identity: null, pname: null, version: null, versionAuthority: null, deriver: null, narHash: null, patches: [], sources: [], missing: [], roles: new Set(), group: null }); } return nodes.get(id); };
|
|
188
|
+
const outputOwner = new Map(); // output path -> {drv, outputName}
|
|
189
|
+
for (const [dp, d] of drvs) { for (const [n, p] of Object.entries(d.outputs)) if (p) outputOwner.set(p, { drv: dp, output: n }); }
|
|
190
|
+
const edges = [];
|
|
191
|
+
const edgeKeys = new Set();
|
|
192
|
+
const addEdge = (from, to, kind, role) => { const k = `${from}\u0000${to}\u0000${kind}`; if (edgeKeys.has(k)) return; if (edges.length >= b.maxEdges) { truncated = true; return; } edgeKeys.add(k); edges.push({ from, to, kind, ...(role ? { role } : {}) }); };
|
|
193
|
+
|
|
194
|
+
for (const [p, info] of pathInfo) {
|
|
195
|
+
const n = node(p, 'output');
|
|
196
|
+
n.narHash = info.narHash || null; n.deriver = info.deriver || null;
|
|
197
|
+
if (info.references === null && info.valid !== false) n.missing.push('references');
|
|
198
|
+
if (!info.narHash) n.missing.push('narHash');
|
|
199
|
+
if (!info.deriver) n.missing.push('deriver');
|
|
200
|
+
if (info.references) for (const r of info.references) if (r !== p) addEdge(p, r, 'reference', null);
|
|
201
|
+
}
|
|
202
|
+
for (const p of queryPaths) { const n = node(p, 'output'); n.queryOnly = !pathInfo.has(p); }
|
|
203
|
+
for (const [dp, d] of drvs) {
|
|
204
|
+
const n = node(dp, 'derivation');
|
|
205
|
+
n.system = d.system; n.outputs = Object.entries(d.outputs).map(([name, path]) => ({ name, path })); n.sources = d.inputSrcs;
|
|
206
|
+
const nv = inferNameVersion(n.storeName, d.env);
|
|
207
|
+
n.pname = nv.pname; n.version = nv.version; n.versionAuthority = nv.authority; n.identity = d.env ? 'derivation-env' : 'drv-path-only';
|
|
208
|
+
if (!d.env) n.missing.push('env');
|
|
209
|
+
if (d.env && typeof d.env.patches === 'string') n.patches = d.env.patches.split(/\s+/).filter(Boolean).map((x) => { const sp = parseStorePath(x); return sp ? { path: sp.path, name: sp.name } : null; }).filter(Boolean).map((x) => ({ path: x.path, name: x.name || x.path }));
|
|
210
|
+
if (d.env && typeof d.env.urls === 'string') n.upstreamUrls = d.env.urls.split(/\s+/).filter(Boolean).slice(0, 8);
|
|
211
|
+
if (d.env && typeof d.env.rev === 'string') n.upstreamRev = d.env.rev;
|
|
212
|
+
// role of each input derivation, from the environment lists that name its output paths
|
|
213
|
+
const rolesFor = (outPaths) => {
|
|
214
|
+
const roles = new Set();
|
|
215
|
+
if (!d.env) return roles;
|
|
216
|
+
for (const [key, role] of ROLE_OF_ENV) { const v = d.env[key]; if (typeof v !== 'string') continue; const set = new Set(v.split(/\s+/)); if (outPaths.some((p) => set.has(p))) roles.add(role); }
|
|
217
|
+
return roles;
|
|
218
|
+
};
|
|
219
|
+
for (const [ip, outs] of Object.entries(d.inputDrvs)) {
|
|
220
|
+
const callee = drvs.get(ip);
|
|
221
|
+
const outPaths = callee ? Object.values(callee.outputs).filter(Boolean) : [];
|
|
222
|
+
const roles = rolesFor(outPaths);
|
|
223
|
+
const role = roles.size ? [...roles].sort().join('|') : 'build';
|
|
224
|
+
node(ip, 'derivation');
|
|
225
|
+
addEdge(dp, ip, 'input-drv', role);
|
|
226
|
+
const e = edges.find((x) => x.from === dp && x.to === ip && x.kind === 'input-drv'); if (e) e.outputsUsed = outs;
|
|
227
|
+
}
|
|
228
|
+
for (const s of d.inputSrcs) { const sn = node(s, 'source'); addEdge(dp, s, 'input-src', 'source'); sn.scopes.add('source'); }
|
|
229
|
+
// link derivation <-> outputs
|
|
230
|
+
for (const [name, op] of Object.entries(d.outputs)) if (op) { const on = node(op, 'output'); on.deriver = on.deriver || dp; on.outputName = name; addEdge(op, dp, 'deriver', null); }
|
|
231
|
+
}
|
|
232
|
+
for (const n of nodes.values()) {
|
|
233
|
+
if (n.kind === 'output') {
|
|
234
|
+
const owner = outputOwner.get(n.id) || (n.deriver && drvs.has(n.deriver) ? { drv: n.deriver } : null);
|
|
235
|
+
const d = owner ? drvs.get(owner.drv) : null;
|
|
236
|
+
if (d) { const nv = inferNameVersion(n.storeName, d.env); n.pname = nv.pname; n.version = nv.version; n.versionAuthority = nv.authority; n.identity = d.env ? 'derivation-env' : 'drv-path-only'; n.system = d.system; n.outputName = owner.output || n.outputName; n.patches = (nodes.get(owner.drv) || {}).patches || []; n.upstreamUrls = (nodes.get(owner.drv) || {}).upstreamUrls; n.upstreamRev = (nodes.get(owner.drv) || {}).upstreamRev; }
|
|
237
|
+
else { const nv = inferNameVersion(n.storeName, null); n.pname = nv.pname; n.version = nv.version; n.versionAuthority = 'inferred-from-store-path'; n.identity = 'output-only'; }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── roots and scopes ──
|
|
242
|
+
let roots = (expected.roots || []).map((r) => parseStorePath(r)).filter(Boolean).map((r) => r.path);
|
|
243
|
+
const fromExpected = roots.length > 0;
|
|
244
|
+
if (!roots.length && pathInfo.size) {
|
|
245
|
+
const referenced = new Set();
|
|
246
|
+
for (const [p, info] of pathInfo) for (const r of info.references || []) if (r !== p) referenced.add(r);
|
|
247
|
+
roots = [...pathInfo.keys()].filter((p) => !referenced.has(p));
|
|
248
|
+
}
|
|
249
|
+
if (!roots.length && queryPaths.size === 1) roots = [...queryPaths];
|
|
250
|
+
const rootDrvs = [];
|
|
251
|
+
for (const r of roots) { const n = nodes.get(r); if (n && n.deriver && drvs.has(n.deriver)) rootDrvs.push(n.deriver); }
|
|
252
|
+
if (roots.length > 1 && !fromExpected) add('ambiguous-root', `${roots.length} paths are not referenced by any other: the target cannot be identified without expected.roots`);
|
|
253
|
+
if (!roots.length) add('no-root', 'no root path could be determined: scopes are not assigned');
|
|
254
|
+
out.roots = roots;
|
|
255
|
+
// runtime closure
|
|
256
|
+
const runtime = new Set(); const rq = [...roots.filter((r) => nodes.has(r))];
|
|
257
|
+
for (const r of rq) runtime.add(r);
|
|
258
|
+
while (rq.length) { const u = rq.shift(); for (const e of edges) if (e.from === u && e.kind === 'reference' && !runtime.has(e.to)) { runtime.add(e.to); rq.push(e.to); } }
|
|
259
|
+
for (const id of runtime) nodes.get(id).scopes.add('runtime');
|
|
260
|
+
// build graph (from the root derivations)
|
|
261
|
+
const buildSeen = new Map(); const bq = [];
|
|
262
|
+
for (const rd of rootDrvs) { buildSeen.set(rd, new Set(['target'])); bq.push(rd); }
|
|
263
|
+
while (bq.length) {
|
|
264
|
+
const u = bq.shift();
|
|
265
|
+
for (const e of edges) if (e.from === u && e.kind === 'input-drv') {
|
|
266
|
+
const roles = new Set(String(e.role || 'build').split('|'));
|
|
267
|
+
const cur = buildSeen.get(e.to) || new Set();
|
|
268
|
+
const before = cur.size;
|
|
269
|
+
for (const r of roles) cur.add(r);
|
|
270
|
+
// roles propagate: a build-only tool's own inputs are build inputs of the target
|
|
271
|
+
if (!buildSeen.has(e.to) || cur.size !== before) { buildSeen.set(e.to, cur); bq.push(e.to); }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const [id, roles] of buildSeen) {
|
|
275
|
+
const n = nodes.get(id); if (!n) continue;
|
|
276
|
+
n.scopes.add('build');
|
|
277
|
+
if ([...roles].every((r) => r === 'test' || r === 'target')) { if (roles.has('test')) n.scopes.add('test'); }
|
|
278
|
+
for (const r of roles) if (r !== 'target') n.roles.add(r);
|
|
279
|
+
}
|
|
280
|
+
// output nodes inherit the build/test scope of their derivation when they were not found in the runtime closure
|
|
281
|
+
for (const n of nodes.values()) if (n.kind === 'output') { const owner = outputOwner.get(n.id); if (owner && buildSeen.has(owner.drv)) { const roles = buildSeen.get(owner.drv); n.scopes.add('build'); if (!n.scopes.has('runtime')) for (const r of roles) if (r !== 'target') n.roles.add(r); if ([...roles].every((r) => r === 'test' || r === 'target') && roles.has('test')) n.scopes.add('test'); } }
|
|
282
|
+
|
|
283
|
+
// same-name different-build groups
|
|
284
|
+
// distinct BUILDS: outputs of one derivation (out, dev, ...) are one build; different derivations are different builds
|
|
285
|
+
const groups = new Map();
|
|
286
|
+
for (const n of nodes.values()) if (n.kind === 'output' && n.pname) { const k = `${n.pname}@${n.version || '?'}`; if (!groups.has(k)) groups.set(k, new Set()); groups.get(k).add((outputOwner.get(n.id) || {}).drv || n.deriver || n.hash); }
|
|
287
|
+
for (const n of nodes.values()) if (n.kind === 'output' && n.pname) { const k = `${n.pname}@${n.version || '?'}`; const g = groups.get(k); n.group = k; n.sameNameDifferentBuild = g.size > 1; }
|
|
288
|
+
|
|
289
|
+
// dangling references / inputDrvs make a graph open
|
|
290
|
+
let openRuntime = false; let openBuild = false;
|
|
291
|
+
for (const [p, info] of pathInfo) for (const r of info.references || []) if (!pathInfo.has(r) && r !== p) { openRuntime = true; }
|
|
292
|
+
for (const d of drvs.values()) for (const ip of Object.keys(d.inputDrvs)) if (!drvs.has(ip)) openBuild = true;
|
|
293
|
+
if (pathInfo.size && openRuntime) add('open-closure', 'some references point at paths that are not in the export: the runtime closure is not closed');
|
|
294
|
+
if (drvs.size && openBuild) add('open-build-graph', 'some inputDrvs are not described in the export: the build graph is not closed');
|
|
295
|
+
for (const n of nodes.values()) if (n.kind === 'output' && n.identity === 'output-only') { if (!dis.some((x) => x.kind === 'output-only-identity')) add('output-only-identity', 'some components are known only by their store path: name and version are inferred from the path, not authoritative', { count: 0 }); dis.find((x) => x.kind === 'output-only-identity').count++; }
|
|
296
|
+
const missingMeta = [...nodes.values()].filter((n) => n.missing.length && n.kind !== 'source');
|
|
297
|
+
if (missingMeta.length) add('missing-metadata', `${missingMeta.length} node(s) lack metadata (${[...new Set(missingMeta.flatMap((n) => n.missing))].join(', ')})`, { count: missingMeta.length });
|
|
298
|
+
|
|
299
|
+
for (const n of nodes.values()) {
|
|
300
|
+
out.nodes.push({ id: n.id, kind: n.kind, componentClass: n.kind === 'source' ? 'source-dependency' : 'resolved-package', hash: n.hash, storeName: n.storeName, pname: n.pname, version: n.version, versionAuthority: n.versionAuthority, identity: n.identity, outputName: n.outputName || null, outputs: n.outputs, system: n.system, scopes: [...n.scopes].sort(), roles: [...n.roles].sort(), deriver: n.deriver, narHash: n.narHash, patches: n.patches, upstream: n.upstreamUrls || n.upstreamRev ? { urls: n.upstreamUrls || [], rev: n.upstreamRev || null } : null, sameNameDifferentBuild: !!n.sameNameDifferentBuild, group: n.group, missing: n.missing });
|
|
301
|
+
}
|
|
302
|
+
out.nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
303
|
+
out.edges = edges.sort((a, b) => `${a.from}${a.to}${a.kind}`.localeCompare(`${b.from}${b.to}${b.kind}`));
|
|
304
|
+
out.coverage = { outputs: out.nodes.filter((n) => n.kind === 'output').length, derivations: out.nodes.filter((n) => n.kind === 'derivation').length, sources: out.nodes.filter((n) => n.kind === 'source').length, edges: out.edges.length, runtimeClosure: runtime.size, buildGraph: buildSeen.size };
|
|
305
|
+
if (truncated) out.status = 'partial';
|
|
306
|
+
else if (malformed) out.status = 'partial';
|
|
307
|
+
|
|
308
|
+
// ── claims ──
|
|
309
|
+
const blocking = (kinds) => dis.some((d) => kinds.includes(d.kind));
|
|
310
|
+
const common = ['foreign-target', 'foreign-revision', 'stale-export', 'unverified-provenance', 'truncated', 'malformed', 'ambiguous-root', 'no-root', 'invalid-signature', 'unsigned'];
|
|
311
|
+
const exactRuntime = pathInfo.size > 0 && !openRuntime && !blocking([...common, 'output-only-identity']) && [...nodes.values()].filter((n) => n.kind === 'output' && runtime.has(n.id)).every((n) => !n.missing.includes('references'));
|
|
312
|
+
const exactBuild = drvs.size > 0 && !openBuild && !blocking(common) && [...nodes.values()].filter((n) => n.kind === 'derivation').every((n) => !n.missing.includes('env'));
|
|
313
|
+
out.claims = claims(exactBuild, exactRuntime, dis);
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function claims(exactBuildGraph, exactRuntimeClosure, dis) {
|
|
318
|
+
return {
|
|
319
|
+
exactBuildGraph, exactRuntimeClosure, exactInventory: exactBuildGraph && exactRuntimeClosure,
|
|
320
|
+
completeness: exactBuildGraph || exactRuntimeClosure ? 'complete-for-supplied-target' : 'not-claimed',
|
|
321
|
+
reasons: dis.filter((d) => !['no-export'].includes(d.kind)).map((d) => d.kind),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function assessProvenance(ex, schema, expected, now, label, add) {
|
|
326
|
+
const p = ex.provenance && typeof ex.provenance === 'object' ? ex.provenance : null;
|
|
327
|
+
const summary = { level: 'none', tool: null, command: null, target: null };
|
|
328
|
+
if (!p) { add('unverified-provenance', `${label}: no provenance envelope (tool, command, target, flake.lock hash): the export cannot be tied to a target`); return { summary }; }
|
|
329
|
+
summary.tool = p.tool || null; summary.command = p.command || null; summary.target = p.target || null; summary.toolVersion = p.toolVersion || null; summary.generatedAt = p.generatedAt || null;
|
|
330
|
+
let level = 'declared';
|
|
331
|
+
if (!SUPPORTED_TOOLS.has(p.tool)) { add('unverified-provenance', `${label}: tool ${p.tool || '(none)'} is not a supported source for ${schema}`); level = 'none'; }
|
|
332
|
+
else if (typeof p.command !== 'string' || !SCHEMA_COMMANDS[schema].test(p.command)) { add('unverified-provenance', `${label}: the recorded command does not produce ${schema}`); level = 'none'; }
|
|
333
|
+
else if (!p.target || typeof p.target !== 'object' || !p.target.system) { add('unverified-provenance', `${label}: provenance names no target system`); level = 'none'; }
|
|
334
|
+
if (level !== 'none') {
|
|
335
|
+
const t = p.target || {};
|
|
336
|
+
if (expected.system && t.system !== expected.system) add('foreign-target', `${label}: export is for system ${t.system}, expected ${expected.system}`);
|
|
337
|
+
if (expected.installable && t.installable !== expected.installable) add('foreign-target', `${label}: export is for ${t.installable || '(unnamed)'}, expected ${expected.installable}`);
|
|
338
|
+
for (const [k, v] of Object.entries(expected.revision || {})) { const got = (p.revision || {})[k]; if (got !== v) add('foreign-revision', `${label}: ${k} revision ${got || '(none)'} differs from the expected ${v}`); }
|
|
339
|
+
if (expected.flakeLockSha256 && p.flakeLockSha256 !== expected.flakeLockSha256) add('stale-export', `${label}: made against a different flake.lock (${String(p.flakeLockSha256 || 'none').slice(0, 12)} vs ${expected.flakeLockSha256.slice(0, 12)})`);
|
|
340
|
+
const at = Date.parse(p.generatedAt);
|
|
341
|
+
if (!Number.isFinite(at)) add('stale-export', `${label}: no usable generation time`);
|
|
342
|
+
else {
|
|
343
|
+
const maxMs = (expected.maxAgeDays ?? 30) * 86400000;
|
|
344
|
+
if (now - at > maxMs) add('stale-export', `${label}: generated ${Math.round((now - at) / 86400000)} days ago (limit ${expected.maxAgeDays ?? 30})`);
|
|
345
|
+
if (Number.isFinite(expected.inputsChangedAtMs) && expected.inputsChangedAtMs > at) add('stale-export', `${label}: the flake inputs changed after this export was generated`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// optional attestation
|
|
349
|
+
if (ex.signature) {
|
|
350
|
+
try {
|
|
351
|
+
const body = JSON.stringify({ schema: ex.schema ?? schema, provenance: p, data: ex.data !== undefined ? ex.data : null, text: ex.text !== undefined ? ex.text : null });
|
|
352
|
+
const v = (0,external_node_crypto_.createVerify)('sha256'); v.update(body);
|
|
353
|
+
if (expected.publicKeyPem && v.verify(expected.publicKeyPem, Buffer.from(ex.signature, 'base64'))) { if (level !== 'none') level = 'signed'; }
|
|
354
|
+
else add('invalid-signature', `${label}: the signature does not verify`);
|
|
355
|
+
} catch (e) { add('invalid-signature', `${label}: signature check failed (${e.message})`); }
|
|
356
|
+
} else if (expected.requireSigned) add('unsigned', `${label}: a signed export is required`);
|
|
357
|
+
summary.level = level;
|
|
358
|
+
return { summary };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Canonical body that a producer signs (exported so tests and producers agree on it). */
|
|
362
|
+
function signedBody(ex) { return JSON.stringify({ schema: ex.schema, provenance: ex.provenance, data: ex.data !== undefined ? ex.data : null, text: ex.text !== undefined ? ex.text : null }); }
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
// EXTERNAL MODULE: ./src/language/haskell-sca.js
|
|
366
|
+
var haskell_sca = __webpack_require__(1426);
|
|
367
|
+
// EXTERNAL MODULE: ./src/language/cvss.js
|
|
368
|
+
var cvss = __webpack_require__(21495);
|
|
369
|
+
// EXTERNAL MODULE: ./src/language/nix-parser.js
|
|
370
|
+
var nix_parser = __webpack_require__(81985);
|
|
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
|
+
|
|
650
|
+
;// CONCATENATED MODULE: ./src/language/nix-sca.js
|
|
651
|
+
// Patch-aware Nix vulnerability matching and reachability (NIX-009).
|
|
652
|
+
//
|
|
653
|
+
// Input is a resolved closure (nix-closure.js), pinned advisory records, optional nixpkgs metadata
|
|
654
|
+
// (`knownVulnerabilities`, license, identifiers) and optional overlay/patch evidence from the Nix source.
|
|
655
|
+
// The outcome for each component is one of:
|
|
656
|
+
//
|
|
657
|
+
// affected the upstream version is inside an affected range and nothing excuses it
|
|
658
|
+
// fixed the version/revision is at or past the fix (or the overlay moves it there)
|
|
659
|
+
// backported-verified in range, BUT a patch whose content hash is in the advisory's fix-patch list is applied
|
|
660
|
+
// possibly-affected in range with an UNVERIFIED patch claim (a CVE-named patch file proves nothing),
|
|
661
|
+
// or only a range overlap, or an ambiguous identity with a plausible match
|
|
662
|
+
// candidate the upstream identity is ambiguous or name-only: matches are leads, never verdicts
|
|
663
|
+
// unknown no usable version/identity/feed: this is NEVER reported as "not affected"
|
|
664
|
+
// not-affected mapped, version outside every affected range
|
|
665
|
+
//
|
|
666
|
+
// A nixpkgs commit, a store hash or a derivation hash is never an upstream software version and is never
|
|
667
|
+
// put in a query. Wrapped Haskell packages reuse the Hackage matcher (PVP ordering). Inclusion in the
|
|
668
|
+
// runtime closure and Haskell import/API reachability are separate evidence tiers: a store path or a build
|
|
669
|
+
// input can establish the first and never the second.
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
const NIX_SCA_VERSION = 'nix-sca/1';
|
|
678
|
+
const HEX40 = /^[0-9a-f]{40}$/i;
|
|
679
|
+
const STORE_HASH = /^[0-9a-df-np-sv-z]{32}$/;
|
|
680
|
+
const UPSTREAM_NOISE = /^(?:git|unstable|master|main|HEAD|latest|unstable-\d{4}-\d{2}-\d{2})$/i;
|
|
681
|
+
|
|
682
|
+
/** A nixpkgs revision, a store-path hash or a date stamp is not an upstream version. */
|
|
683
|
+
function isNotAnUpstreamVersion(v) {
|
|
684
|
+
if (typeof v !== 'string' || !v) return 'no version';
|
|
685
|
+
if (HEX40.test(v)) return 'a 40-hex string is a git revision (for example a nixpkgs commit), not a software version';
|
|
686
|
+
if (STORE_HASH.test(v)) return 'a 32-character base32 string is a store-path hash, not a software version';
|
|
687
|
+
if (/^[0-9a-f]{7,12}$/i.test(v) && /[a-f]/i.test(v)) return 'a short hexadecimal string is a revision, not a software version';
|
|
688
|
+
if (UPSTREAM_NOISE.test(v)) return `"${v}" is a moving ref, not a software version`;
|
|
689
|
+
if (!/\d/.test(v)) return `"${v}" has no numeric component`;
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── generic version ordering for upstream software ───────────────────────────
|
|
694
|
+
const PRE = /^(?:alpha|beta|rc|pre|dev|snapshot|a|b)$/i;
|
|
695
|
+
function tokens(v) { return String(v).toLowerCase().replace(/^v(?=\d)/, '').split(/[^a-z0-9]+/).filter(Boolean).flatMap((t) => t.match(/\d+|[a-z]+/g) || []); }
|
|
696
|
+
function compareUpstream(a, b) {
|
|
697
|
+
const x = tokens(a), y = tokens(b);
|
|
698
|
+
const n = Math.max(x.length, y.length);
|
|
699
|
+
for (let i = 0; i < n; i++) {
|
|
700
|
+
const p = x[i], q = y[i];
|
|
701
|
+
if (p === undefined) return PRE.test(q) ? 1 : (/^\d+$/.test(q) && Number(q) === 0 ? 0 : -1);
|
|
702
|
+
if (q === undefined) return PRE.test(p) ? -1 : (/^\d+$/.test(p) && Number(p) === 0 ? 0 : 1);
|
|
703
|
+
const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
|
|
704
|
+
if (pn && qn) { const d = Number(p) - Number(q); if (d) return d < 0 ? -1 : 1; continue; }
|
|
705
|
+
if (pn !== qn) { if (!pn && PRE.test(p)) return -1; if (!qn && PRE.test(q)) return 1; return pn ? 1 : -1; }
|
|
706
|
+
if (p !== q) return p < q ? -1 : 1;
|
|
707
|
+
}
|
|
708
|
+
return 0;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── advisories ───────────────────────────────────────────────────────────────
|
|
712
|
+
const sha = (s) => String(s || '').toLowerCase().replace(/^sha256[-:]/, '');
|
|
713
|
+
function eventsToRanges(events) {
|
|
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;
|
|
716
|
+
for (const e of events || []) {
|
|
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; }
|
|
721
|
+
}
|
|
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 });
|
|
723
|
+
return out;
|
|
724
|
+
}
|
|
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));
|
|
726
|
+
|
|
727
|
+
/** Normalize a non-Hackage OSV-style record into what Nix matching needs. */
|
|
728
|
+
function normalizeGenericAdvisory(rec) {
|
|
729
|
+
if (!rec || typeof rec.id !== 'string') return null;
|
|
730
|
+
const aliases = [...new Set((rec.aliases || []).filter((x) => typeof x === 'string'))];
|
|
731
|
+
const affected = [];
|
|
732
|
+
for (const a of rec.affected || []) {
|
|
733
|
+
if (!a || !a.package) continue;
|
|
734
|
+
const ranges = (a.ranges || []).filter((r) => r.type !== 'GIT').flatMap((r) => eventsToRanges(r.events));
|
|
735
|
+
const git = (a.ranges || []).filter((r) => r.type === 'GIT').map((r) => ({ repo: r.repo || null, events: r.events || [] }));
|
|
736
|
+
affected.push({
|
|
737
|
+
ecosystem: a.package.ecosystem || null, name: a.package.name || null, purl: a.package.purl || null, cpe: a.package.cpe || null,
|
|
738
|
+
ranges, versions: Array.isArray(a.versions) ? a.versions : [], git,
|
|
739
|
+
fixedIn: [...new Set((a.ranges || []).flatMap((r) => (r.events || []).filter((e) => 'fixed' in e).map((e) => e.fixed)))],
|
|
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 })),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
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 };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
class NixAdvisoryData {
|
|
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;
|
|
752
|
+
this.records = records.map(normalizeGenericAdvisory).filter(Boolean);
|
|
753
|
+
this.hackage = hackage; // an AdvisoryDb (HS-009) for wrapped Haskell packages
|
|
754
|
+
const gen = generatedAt ? Date.parse(generatedAt) : NaN;
|
|
755
|
+
this.ageDays = Number.isFinite(gen) ? (now - gen) / 86400000 : null;
|
|
756
|
+
this.stale = this.ageDays === null ? true : this.ageDays > maxAgeDays;
|
|
757
|
+
this.feed = { source, generatedAt, status: this.stale ? 'stale-cache' : 'current', records: this.records.length };
|
|
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
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ── upstream identity ────────────────────────────────────────────────────────
|
|
769
|
+
const HASKELL_ENV_HINTS = ['libraryHaskellDepends', 'setupHaskellDepends', 'executableHaskellDepends', 'isLibrary', 'isExecutable', 'enableSeparateDataOutput', 'compilerName', 'enableLibraryProfiling'];
|
|
770
|
+
function purlFromUrl(u) {
|
|
771
|
+
const m = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:\/|$)/.exec(u || '');
|
|
772
|
+
if (m) return { purl: `pkg:github/${m[1].toLowerCase()}/${m[2].toLowerCase()}`, host: 'github' };
|
|
773
|
+
const g = /^https?:\/\/gitlab\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:\/|$)/.exec(u || '');
|
|
774
|
+
if (g) return { purl: `pkg:gitlab/${g[1].toLowerCase()}/${g[2].toLowerCase()}`, host: 'gitlab' };
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* The identity a component can be matched under. Never invents authority: `explicit` needs the package
|
|
780
|
+
* metadata to say so, `src-derived` needs a recognised source host, anything else is `name-only`.
|
|
781
|
+
*/
|
|
782
|
+
function upstreamIdentity(node, drvEnv, meta, overlay) {
|
|
783
|
+
const cands = [];
|
|
784
|
+
const version = (overlay && overlay.version) || node.version;
|
|
785
|
+
const vguard = isNotAnUpstreamVersion(version);
|
|
786
|
+
const base = { pname: node.pname, version: vguard ? null : version, versionRejected: vguard || null, versionSource: overlay && overlay.version ? 'overlay' : node.versionAuthority };
|
|
787
|
+
const isHs = drvEnv && HASKELL_ENV_HINTS.some((k) => k in drvEnv);
|
|
788
|
+
if (isHs && node.pname) cands.push({ ecosystem: 'Hackage', name: node.pname, purl: (0,haskell_sca/* hackagePurl */.Gf)(node.pname, base.version), authority: 'explicit', basis: 'Haskell build attributes in the derivation' });
|
|
789
|
+
const ids = meta && meta.identifiers ? meta.identifiers : null;
|
|
790
|
+
if (ids && typeof ids.purl === 'string') cands.push({ purl: ids.purl, authority: 'explicit', basis: 'meta.identifiers.purl' });
|
|
791
|
+
const cpes = ids ? [].concat(ids.cpe || [], ids.possibleCPEs || [], ids.v1 && ids.v1.cpeParts ? [ids.v1.cpeParts] : []).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
|
|
793
|
+
const uniqueCpe = [...new Set(cpeStrings)];
|
|
794
|
+
if (uniqueCpe.length === 1 && ids && ids.cpe && !ids.possibleCPEs) cands.push({ cpe: uniqueCpe[0], authority: 'explicit', basis: 'meta.identifiers.cpe' });
|
|
795
|
+
else for (const c of uniqueCpe) cands.push({ cpe: c, authority: 'candidate', basis: uniqueCpe.length > 1 ? 'one of several possible CPEs' : 'possible CPE' });
|
|
796
|
+
for (const u of (node.upstream && node.upstream.urls) || []) { const p = purlFromUrl(u); if (p) cands.push({ purl: p.purl, authority: 'src-derived', basis: `source URL host ${p.host}` }); }
|
|
797
|
+
if (meta && meta.homepage) { const p = purlFromUrl(meta.homepage); if (p) cands.push({ purl: p.purl, authority: 'src-derived', basis: 'meta.homepage' }); }
|
|
798
|
+
if (!cands.some((c) => c.authority === 'explicit' || c.authority === 'src-derived') && node.pname) cands.push({ name: node.pname, authority: 'name-only', basis: 'store/derivation name only' });
|
|
799
|
+
const uniq = []; const seen = new Set();
|
|
800
|
+
for (const c of cands) { const k = JSON.stringify([c.ecosystem, c.name, c.purl, c.cpe, c.authority]); if (!seen.has(k)) { seen.add(k); uniq.push(c); } }
|
|
801
|
+
const strong = uniq.filter((c) => c.authority === 'explicit' || c.authority === 'src-derived');
|
|
802
|
+
const distinctTargets = new Set(strong.map((c) => c.purl || c.cpe || `${c.ecosystem}:${c.name}`));
|
|
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 };
|
|
804
|
+
}
|
|
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
|
+
|
|
832
|
+
// ── overlays and patches ─────────────────────────────────────────────────────
|
|
833
|
+
const segName = (s) => (s && s.kind === 'static' ? s.name : null);
|
|
834
|
+
const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
|
|
835
|
+
|
|
836
|
+
/** pname -> {version?, patches:[{name,source,sha256?,url?}], file, line} from overlay `overrideAttrs` calls. */
|
|
837
|
+
function overlayEvidence(files) {
|
|
838
|
+
const out = {};
|
|
839
|
+
for (const [file, text] of Object.entries(files || {})) {
|
|
840
|
+
if (!/\.nix$/i.test(file) || typeof text !== 'string') continue;
|
|
841
|
+
const parse = (0,nix_parser/* parseNix */.Z$)(text, { file });
|
|
842
|
+
if (!parse.ast) continue;
|
|
843
|
+
const stack = [parse.ast];
|
|
844
|
+
while (stack.length) {
|
|
845
|
+
const n = stack.pop();
|
|
846
|
+
if (!n || typeof n !== 'object') continue;
|
|
847
|
+
if (n.type === 'attrset') {
|
|
848
|
+
for (const b of n.bindings || []) {
|
|
849
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
850
|
+
const key = (b.path || []).map(segName);
|
|
851
|
+
if (key.length !== 1 || !key[0]) continue;
|
|
852
|
+
const call = unparen(b.value);
|
|
853
|
+
if (!call || call.type !== 'app') continue;
|
|
854
|
+
const fn = unparen(call.fn);
|
|
855
|
+
const last = fn && fn.type === 'select' ? segName(fn.attrpath[fn.attrpath.length - 1]) : null;
|
|
856
|
+
if (last !== 'overrideAttrs') continue;
|
|
857
|
+
const rec = out[key[0]] || (out[key[0]] = { patches: [], file, line: b.span ? b.span.startLine : null, mechanism: 'overlay-overrideAttrs' });
|
|
858
|
+
let body = unparen(call.arg);
|
|
859
|
+
while (body && body.type === 'lambda') body = unparen(body.body);
|
|
860
|
+
if (body && body.type === 'attrset') {
|
|
861
|
+
for (const ob of body.bindings) {
|
|
862
|
+
if (ob.kind !== 'attr') continue;
|
|
863
|
+
const k = (ob.path || []).map(segName).join('.');
|
|
864
|
+
if (k === 'version') { const v = unparen(ob.value); if (v && v.literal !== undefined) rec.version = v.literal; }
|
|
865
|
+
if (k === 'patches') collectPatches(ob.value, rec.patches);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
for (const k of Object.keys(n)) { const c = n[k]; if (c && typeof c === 'object') { if (Array.isArray(c)) stack.push(...c); else stack.push(c); } }
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
875
|
+
function collectPatches(node, acc) {
|
|
876
|
+
const n = unparen(node);
|
|
877
|
+
if (!n) return;
|
|
878
|
+
if (n.type === 'list') for (const it of n.items) collectPatches(it, acc);
|
|
879
|
+
else if (n.type === 'binop') { collectPatches(n.left, acc); collectPatches(n.right, acc); }
|
|
880
|
+
else if (n.type === 'path' && n.literal) acc.push({ name: String(n.literal).replace(/^.*\//, ''), source: 'path', sha256: null });
|
|
881
|
+
else if (n.type === 'app') {
|
|
882
|
+
const flat = []; let f = n; while (f && f.type === 'app') { flat.unshift(f.arg); f = unparen(f.fn); }
|
|
883
|
+
const nm = f && f.type === 'select' ? segName(f.attrpath[f.attrpath.length - 1]) : (f && f.type === 'ident' ? f.name : null);
|
|
884
|
+
if (nm === 'fetchpatch' || nm === 'fetchpatch2') {
|
|
885
|
+
const a = unparen(flat[0]); let url = null; let hash = null;
|
|
886
|
+
if (a && a.type === 'attrset') for (const b of a.bindings) { if (b.kind !== 'attr') continue; const k = (b.path || []).map(segName).join('.'); const v = unparen(b.value); if (k === 'url' && v) url = v.literal ?? null; if ((k === 'sha256' || k === 'hash') && v) hash = v.literal ?? null; }
|
|
887
|
+
acc.push({ name: url ? url.replace(/[?#].*$/, '').replace(/^.*\//, '') : 'fetchpatch', source: 'fetchpatch', url, sha256: hash ? sha(hash) : null });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ── matching ─────────────────────────────────────────────────────────────────
|
|
893
|
+
const CVE_IN_NAME = /CVE-\d{4}-\d{4,}/i;
|
|
894
|
+
function patchEvidence(patches, advisory, aff) {
|
|
895
|
+
const claims = []; let verified = null;
|
|
896
|
+
const fixHashes = new Set((aff.fixPatches || []).map((p) => p.sha256).filter(Boolean));
|
|
897
|
+
const fixNames = new Set((aff.fixPatches || []).map((p) => p.name).filter(Boolean));
|
|
898
|
+
for (const p of patches || []) {
|
|
899
|
+
const cveInName = (CVE_IN_NAME.exec(p.name || '') || [])[0];
|
|
900
|
+
const namesThis = cveInName && advisory.ids.some((i) => i.toUpperCase() === cveInName.toUpperCase());
|
|
901
|
+
if (p.sha256 && fixHashes.has(sha(p.sha256))) { verified = verified || { patch: p.name, by: 'content hash matches an advisory fix patch' }; continue; }
|
|
902
|
+
if (namesThis || (p.name && fixNames.has(p.name))) claims.push({ patch: p.name, claim: namesThis ? `named after ${cveInName}` : 'name matches an advisory fix patch', verified: false, why: p.sha256 ? 'its content hash is not one the advisory lists' : 'no content hash: a file name proves nothing about what the patch does' });
|
|
903
|
+
}
|
|
904
|
+
return { verified, claims };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function genericMatch(adv, aff, ident, node, patches) {
|
|
908
|
+
const v = ident.version;
|
|
909
|
+
const out = { status: 'unknown', reason: '' };
|
|
910
|
+
if (aff.git.length && node.upstream && node.upstream.rev) {
|
|
911
|
+
const rev = node.upstream.rev.toLowerCase();
|
|
912
|
+
const fixed = aff.git.flatMap((g) => g.events.filter((e) => 'fixed' in e).map((e) => String(e.fixed).toLowerCase()));
|
|
913
|
+
const listed = new Set([...(aff.versions || []).map((x) => String(x).toLowerCase()), ...aff.git.flatMap((g) => g.events.filter((e) => 'introduced' in e || 'last_affected' in e).map((e) => String(e.introduced ?? e.last_affected).toLowerCase()))]);
|
|
914
|
+
if (fixed.includes(rev)) return { status: 'fixed', reason: 'the source revision is the advisory fix commit' };
|
|
915
|
+
if ([...listed].includes(rev)) return { status: 'affected', reason: 'the source revision is an affected commit listed by the advisory' };
|
|
916
|
+
out.reason = 'the source revision is neither the fix nor a listed affected commit; commit ordering is not available, so no verdict';
|
|
917
|
+
if (!v) return out;
|
|
918
|
+
}
|
|
919
|
+
if (!v) return { status: 'unknown', reason: ident.versionRejected || 'no upstream version' };
|
|
920
|
+
let inside = aff.ranges.some((r) => inRange(v, r));
|
|
921
|
+
if (!inside && aff.versions.some((x) => compareUpstream(v, x) === 0)) inside = true;
|
|
922
|
+
if (!aff.ranges.length && !aff.versions.length) return { status: 'unknown', reason: 'the advisory has no version information for this package' };
|
|
923
|
+
if (inside) return { status: 'affected', reason: 'the version is inside an affected range' };
|
|
924
|
+
if ((aff.fixedIn || []).some((f) => compareUpstream(v, f) >= 0)) return { status: 'fixed', reason: `the version is at or past the fix (${aff.fixedIn.join(', ')})` };
|
|
925
|
+
return { status: 'not-affected', reason: 'the version is outside every affected range' };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* @param {{closure: object, data: NixAdvisoryData|null, meta?: Record<string,object>, overlays?: object,
|
|
930
|
+
* haskellUsage?: {imports: object[], callees: Set<string>}, services?: Array<{service:string, packages:string[]}>,
|
|
931
|
+
* symbols?: Record<string,string[]>, kev?: Set<string>|null, epss?: Record<string,number>|null,
|
|
932
|
+
* licensePolicy?: object, drvEnv?: Record<string,object>}} opts
|
|
933
|
+
* meta is keyed by derivation name or pname; drvEnv by derivation path (the env block of the drv show export).
|
|
934
|
+
*/
|
|
935
|
+
function matchNixVulnerabilities(opts = {}) {
|
|
936
|
+
const { closure, data = null, meta = {}, overlays = {}, haskellUsage = null, services = [], symbols = {}, kev = null, epss = null } = opts;
|
|
937
|
+
const findings = []; const statuses = [];
|
|
938
|
+
const feed = data ? data.feed : { source: null, generatedAt: null, status: 'feed-unavailable', records: 0 };
|
|
939
|
+
const nodes = (closure && closure.nodes) || [];
|
|
940
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
941
|
+
// one subject per derivation: its outputs are one build
|
|
942
|
+
const subjects = new Map();
|
|
943
|
+
const roots = new Set((closure && closure.roots) || []);
|
|
944
|
+
for (const n of nodes) {
|
|
945
|
+
if (n.kind !== 'output' || roots.has(n.id)) continue; // the target itself is not a dependency of itself
|
|
946
|
+
const key = n.deriver || n.id;
|
|
947
|
+
if (!subjects.has(key)) subjects.set(key, { drv: n.deriver || null, outputs: [], node: n });
|
|
948
|
+
const s = subjects.get(key); s.outputs.push(n);
|
|
949
|
+
if (n.outputName === 'out' || (s.node.outputName !== 'out' && n.scopes.includes('runtime'))) s.node = n;
|
|
950
|
+
}
|
|
951
|
+
const summary = { components: 0, matched: 0, mappedNoMatch: 0, unmapped: 0, candidates: 0 };
|
|
952
|
+
for (const s of subjects.values()) {
|
|
953
|
+
const node = s.node;
|
|
954
|
+
const env = (opts.drvEnv && s.drv && opts.drvEnv[s.drv]) || null;
|
|
955
|
+
const m = meta[node.pname] || meta[node.storeName] || null;
|
|
956
|
+
const ov = overlays[node.pname] || null;
|
|
957
|
+
const patches = [...(node.patches || []), ...((ov && ov.patches) || [])];
|
|
958
|
+
const ident = upstreamIdentity(node, env, m, ov);
|
|
959
|
+
summary.components++;
|
|
960
|
+
const nixBuild = { derivation: s.drv, outputs: s.outputs.map((o) => ({ name: o.outputName, path: o.id })), system: node.system, storeHash: node.hash, patches: patches.map((p) => ({ name: p.name, source: p.source || 'derivation', sha256: p.sha256 || null })), overlay: ov ? { file: ov.file, line: ov.line, versionOverride: ov.version || null } : null, scopes: node.scopes };
|
|
961
|
+
const inclusion = node.scopes.includes('runtime') ? 'runtime-closure' : (node.scopes.length ? 'build-only' : 'unknown');
|
|
962
|
+
const svc = services.filter((x) => x.packages.some((p) => p === node.pname || p === node.storeName)).map((x) => x.service);
|
|
963
|
+
const tiers = { inclusion, services: svc, reachability: { import: 'unknown', function: 'unknown', basis: 'a store path or a build input proves inclusion only, never that code is called' } };
|
|
964
|
+
const base = { name: node.pname, version: ident.version, nixBuild, identity: { authority: ident.authority, candidates: ident.candidates, versionSource: ident.versionSource, versionRejected: ident.versionRejected }, tiers, feed };
|
|
965
|
+
const push = (st, extra = {}) => statuses.push({ ...base, status: st.status, reason: st.reason, ...extra });
|
|
966
|
+
|
|
967
|
+
// knownVulnerabilities marked by nixpkgs themselves
|
|
968
|
+
const kvs = (m && Array.isArray(m.knownVulnerabilities) ? m.knownVulnerabilities : []).filter((x) => typeof x === 'string');
|
|
969
|
+
for (const kv of kvs) findings.push(finding(base, { id: `nixpkgs:knownVulnerabilities:${node.pname}`, ids: [kv.match(CVE_IN_NAME) ? kv.match(CVE_IN_NAME)[0] : `nixpkgs-known-vulnerable:${node.pname}`], summary: kv }, 'affected', 'nixpkgs marks this package with knownVulnerabilities', { source: 'nixpkgs-meta', ghc: false }, { kev, epss }));
|
|
970
|
+
if (!data) { push({ status: 'unknown', reason: 'no advisory feed was supplied: absence of a match is not a clean result' }); summary.unmapped++; continue; }
|
|
971
|
+
if (ident.versionRejected && !ident.haskell && !(node.upstream && node.upstream.rev)) { push({ status: 'unknown', reason: `the version cannot be queried: ${ident.versionRejected}` }); summary.unmapped++; continue; }
|
|
972
|
+
|
|
973
|
+
let matched = false; let mapped = false; let hackageGap = null;
|
|
974
|
+
// Hackage (wrapped Haskell packages): reuse the PVP matcher
|
|
975
|
+
if (ident.haskell && data.hackage && ident.version && (0,haskell_manifests/* parseVersion */.ot)(ident.version) && data.hackage.coverage(node.pname) !== 'covered') {
|
|
976
|
+
// A feed that never looked up this package (or looked too long ago) says nothing about it: that is unknown, not "no advisory matches".
|
|
977
|
+
hackageGap = data.hackage.coverage(node.pname) === 'stale'
|
|
978
|
+
? 'the Hackage advisory feed last covered this package longer ago than its age limit: the absence of a match is not a clean result'
|
|
979
|
+
: 'the Hackage advisory feed never covered this package: the absence of a match is not a clean result';
|
|
980
|
+
} else if (ident.haskell && data.hackage && ident.version && (0,haskell_manifests/* parseVersion */.ot)(ident.version)) {
|
|
981
|
+
mapped = true;
|
|
982
|
+
for (const { adv, aff } of data.hackage.forPackage(node.pname)) {
|
|
983
|
+
if (adv.withdrawn) continue;
|
|
984
|
+
const r = (0,haskell_sca/* matchComponent */.nZ)(aff, { name: node.pname, version: ident.version });
|
|
985
|
+
if (r.status === 'not-affected') continue;
|
|
986
|
+
const pe = patchEvidence(patches, { ids: adv.ids }, { fixPatches: [] });
|
|
987
|
+
const st = decide(r, pe, ident);
|
|
988
|
+
matched = true;
|
|
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 }; }
|
|
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 }));
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
// generic records by PURL / CPE; only authoritative identities are matched, candidates are leads
|
|
994
|
+
for (const adv of data.records) {
|
|
995
|
+
if (adv.withdrawn) continue;
|
|
996
|
+
for (const aff of adv.affected) {
|
|
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));
|
|
998
|
+
if (!target) continue;
|
|
999
|
+
if (aff.ecosystem === 'Hackage') continue; // handled above
|
|
1000
|
+
mapped = mapped || target.authority === 'explicit' || target.authority === 'src-derived';
|
|
1001
|
+
const gm = genericMatch(adv, aff, ident, node, patches);
|
|
1002
|
+
if (gm.status === 'not-affected' && target.authority !== 'candidate' && target.authority !== 'name-only') continue;
|
|
1003
|
+
const pe = patchEvidence(patches, adv, aff);
|
|
1004
|
+
let st = gm.status === 'affected' ? decide(gm, pe, ident) : gm;
|
|
1005
|
+
// identity gate: only an authoritative, unambiguous identity may produce a firm verdict
|
|
1006
|
+
if (target.authority === 'candidate' || target.authority === 'name-only' || ident.ambiguous) {
|
|
1007
|
+
st = { status: gm.status === 'affected' || gm.status === 'possibly-affected' ? 'candidate' : (gm.status === 'fixed' ? 'candidate' : gm.status === 'not-affected' ? 'unknown' : gm.status), reason: `${gm.reason}; the upstream identity is ${target.authority === 'candidate' ? 'ambiguous (several possible CPEs)' : 'name-only'}, so this is a lead, not a verdict` };
|
|
1008
|
+
summary.candidates++;
|
|
1009
|
+
}
|
|
1010
|
+
matched = true;
|
|
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 }));
|
|
1012
|
+
}
|
|
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
|
+
}
|
|
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 */ }
|
|
1029
|
+
else if (hackageGap) { summary.unmapped++; push({ status: 'unknown', reason: hackageGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
|
|
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 }); }
|
|
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 }); }
|
|
1032
|
+
}
|
|
1033
|
+
return { version: NIX_SCA_VERSION, findings, statuses, feed, summary, licenses: licenseReport(nodes.filter((n) => !roots.has(n.id)), meta, opts.licensePolicy) };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function decide(r, pe, ident) {
|
|
1037
|
+
if (r.status === 'possibly-affected') return { status: 'possibly-affected', reason: r.reason };
|
|
1038
|
+
if (pe.verified) return { status: 'backported-verified', reason: `in the affected range, but ${pe.verified.by} (${pe.verified.patch})` };
|
|
1039
|
+
if (pe.claims.length) return { status: 'possibly-affected', reason: `in the affected range with an UNVERIFIED patch claim (${pe.claims[0].patch}: ${pe.claims[0].why})` };
|
|
1040
|
+
return { status: 'affected', reason: r.reason };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function finding(base, adv, status, reason, extra, { kev, epss }) {
|
|
1044
|
+
const cves = adv.cves || (adv.ids || []).filter((x) => /^CVE-/.test(x));
|
|
1045
|
+
const kevHit = cves.length ? (kev ? cves.some((c) => kev.has(c)) : 'unknown') : 'not-applicable';
|
|
1046
|
+
const ep = cves.map((c) => epss && epss[c]).filter((x) => typeof x === 'number');
|
|
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');
|
|
1052
|
+
return {
|
|
1053
|
+
type: 'vulnerable_dep', ecosystem: 'nix', language: 'nix', capability: 'sca', analysisKind: 'application', evidenceKind: 'closure',
|
|
1054
|
+
name: base.name, version: base.version, osvId: adv.id, ids: adv.ids || [adv.id], cveAliases: cves, summary: adv.summary || '',
|
|
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 } : {}),
|
|
1056
|
+
nixBuild: base.nixBuild, identity: base.identity, tiers: base.tiers, feed: base.feed,
|
|
1057
|
+
kev: kevHit, epss: ep.length ? Math.max(...ep) : 'unknown',
|
|
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 },
|
|
1059
|
+
file: null, line: null, vuln: `Vulnerable package in the Nix closure (${status})`, cwe: 'CWE-1104', parser: 'NIX-SCA', family: 'sca',
|
|
1060
|
+
description: `${base.name}${base.version ? ` ${base.version}` : ''} (${adv.id}): ${reason}.`,
|
|
1061
|
+
remediation: (extra.fixedIn && extra.fixedIn.length) ? `Update ${base.name} to ${extra.fixedIn.join(' or ')} (override the input or overlay), then regenerate the closure export.` : 'No fixed version is recorded in the advisory data.',
|
|
1062
|
+
confidence: status === 'affected' ? 0.85 : status === 'possibly-affected' ? 0.5 : 0.3,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function licenseReport(nodes, meta, policy) {
|
|
1067
|
+
const comps = nodes.filter((n) => n.kind === 'output' && (n.outputName === 'out' || !n.outputName)).map((n) => ({ name: n.pname, version: n.version }));
|
|
1068
|
+
const md = {};
|
|
1069
|
+
for (const c of comps) { const m = meta[c.name]; if (m && m.license) md[c.name] = { license: typeof m.license === 'string' ? m.license : (m.license.spdxId || m.license.shortName || null) }; }
|
|
1070
|
+
return (0,haskell_sca/* licensePolicy */.ez)(comps, md, policy);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
// EXTERNAL MODULE: ./src/language/haskell-supply.js
|
|
1076
|
+
var haskell_supply = __webpack_require__(67052);
|
|
1077
|
+
// EXTERNAL MODULE: external "node:child_process"
|
|
1078
|
+
var external_node_child_process_ = __webpack_require__(31421);
|
|
1079
|
+
// EXTERNAL MODULE: external "node:net"
|
|
1080
|
+
var external_node_net_ = __webpack_require__(77030);
|
|
1081
|
+
// EXTERNAL MODULE: external "node:os"
|
|
1082
|
+
var external_node_os_ = __webpack_require__(48161);
|
|
1083
|
+
;// CONCATENATED MODULE: ./src/language/nix-eval-isolation.js
|
|
1084
|
+
// Isolated, opt-in Nix evaluation (NIX-011).
|
|
1085
|
+
//
|
|
1086
|
+
// Ordinary scanning NEVER evaluates customer Nix: nothing imports this module on the default path. An
|
|
1087
|
+
// operator who explicitly asks for evaluation gets it only inside an OS sandbox whose isolation this module
|
|
1088
|
+
// has just proved with a probe, and only through an evaluator whose required safety flags were
|
|
1089
|
+
// feature-detected. The order is fixed and every step before the last is "before project code runs":
|
|
1090
|
+
//
|
|
1091
|
+
// 1. detectSandbox which backend exists on this host (macOS sandbox-exec, Linux bubblewrap), or none
|
|
1092
|
+
// 2. probeSandbox run a probe INSIDE the sandbox that tries to read outside the allowlist, write
|
|
1093
|
+
// outside it, reach the network, reach a daemon socket, spawn a process and see a
|
|
1094
|
+
// credential variable; the sandbox is accepted only if every attempt is refused
|
|
1095
|
+
// 3. detectEvaluator run `--version` / `eval --help` and require every safety flag to exist
|
|
1096
|
+
// 4. runIsolatedEval run the target-scoped evaluation with a clean environment, a read-only allowlist,
|
|
1097
|
+
// a wall-clock deadline, an output cap and a memory ceiling; kill the whole process group
|
|
1098
|
+
//
|
|
1099
|
+
// Anything that fails earlier returns `unsupported` or `blocked` with `projectCodeEvaluated:false`; the
|
|
1100
|
+
// static findings of the scan are never touched (`staticFindingsRetained:true`) and the result says how to
|
|
1101
|
+
// import a supplied export instead. Flags are NOT the boundary: the filesystem/network/process boundary is
|
|
1102
|
+
// the sandbox, and the probe tests it independently of any flag. Nix's own build sandbox and restricted
|
|
1103
|
+
// evaluation are additional controls, not substitutes. Nothing here runs `nixos-rebuild`, activates a
|
|
1104
|
+
// configuration, builds anything or edits a host setting.
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
const NIX_EVAL_VERSION = 'nix-eval-isolation/1';
|
|
1114
|
+
const DEFAULT_LIMITS = Object.freeze({ deadlineMs: 20_000, maxOutputBytes: 1 << 20, maxRssMb: 512, killGraceMs: 1500, probeDeadlineMs: 15_000 });
|
|
1115
|
+
/** Flags the evaluator must support; a missing one means `unsupported`, never "run without it". */
|
|
1116
|
+
const REQUIRED_EVAL_FLAGS = Object.freeze(['--offline', '--no-update-lock-file', '--no-write-lock-file', '--pure-eval', '--json', '--option']);
|
|
1117
|
+
/** `--option name value` settings that are always passed. */
|
|
1118
|
+
const SAFETY_OPTIONS = Object.freeze({
|
|
1119
|
+
'allow-import-from-derivation': 'false',
|
|
1120
|
+
'allow-unsafe-native-code-during-evaluation': 'false',
|
|
1121
|
+
'accept-flake-config': 'false',
|
|
1122
|
+
'restrict-eval': 'true',
|
|
1123
|
+
'allowed-uris': '',
|
|
1124
|
+
'substituters': '',
|
|
1125
|
+
'max-jobs': '0',
|
|
1126
|
+
});
|
|
1127
|
+
const CLEAN_PATH = '/usr/bin:/bin';
|
|
1128
|
+
const ATTR_RE = /^[A-Za-z0-9_][A-Za-z0-9_.+'-]*(?:\.(?:[A-Za-z0-9_][A-Za-z0-9_+'-]*|"[A-Za-z0-9_.+-]+"))*$/;
|
|
1129
|
+
|
|
1130
|
+
// ── 1. sandbox backend ───────────────────────────────────────────────────────
|
|
1131
|
+
const which = (bin) => { const r = (0,external_node_child_process_.spawnSync)('/usr/bin/which', [bin], { encoding: 'utf8', timeout: 5000 }); return r.status === 0 ? r.stdout.trim() : null; };
|
|
1132
|
+
|
|
1133
|
+
function detectSandbox(opts = {}) {
|
|
1134
|
+
const plat = opts.platform || (0,external_node_os_.platform)();
|
|
1135
|
+
const find = opts.which || which;
|
|
1136
|
+
if (opts.forceBackend === 'none') return { available: false, backend: 'none', platform: plat, reason: 'isolation disabled by the caller' };
|
|
1137
|
+
if (plat === 'darwin') {
|
|
1138
|
+
const p = find('sandbox-exec');
|
|
1139
|
+
return p ? { available: true, backend: 'sandbox-exec', path: p, platform: plat, reason: null } : { available: false, backend: 'none', platform: plat, reason: 'sandbox-exec is not installed' };
|
|
1140
|
+
}
|
|
1141
|
+
if (plat === 'linux') {
|
|
1142
|
+
const p = find('bwrap');
|
|
1143
|
+
return p ? { available: true, backend: 'bwrap', path: p, platform: plat, reason: null } : { available: false, backend: 'none', platform: plat, reason: 'bubblewrap (bwrap) is not installed: no tested unprivileged filesystem/network namespace is available' };
|
|
1144
|
+
}
|
|
1145
|
+
return { available: false, backend: 'none', platform: plat, reason: `no sandbox backend is implemented for ${plat}` };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const sbq = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
1149
|
+
const real = (p) => { try { return (0,external_node_fs_.realpathSync)(p); } catch { return (0,external_node_path_.resolve)(p); } };
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* The sandboxed command for a backend. Reads are an ALLOWLIST (the listed roots, system library paths and the
|
|
1153
|
+
* evaluator); writes are an allowlist of one scratch directory; network and process creation are denied.
|
|
1154
|
+
* @param {{backend:string, path?:string}} sb
|
|
1155
|
+
* @param {{execFile:string, args:string[], readOnly:string[], readFiles?:string[], writable:string[], cwd:string}} spec
|
|
1156
|
+
*/
|
|
1157
|
+
function sandboxCommand(sb, spec) {
|
|
1158
|
+
const ro = [...new Set(spec.readOnly.map(real))];
|
|
1159
|
+
const files = [...new Set((spec.readFiles || []).map(real))];
|
|
1160
|
+
const rw = [...new Set(spec.writable.map(real))];
|
|
1161
|
+
const exec = real(spec.execFile);
|
|
1162
|
+
if (sb.backend === 'sandbox-exec') {
|
|
1163
|
+
const profile = [
|
|
1164
|
+
'(version 1)', '(allow default)', '(deny network*)',
|
|
1165
|
+
'(deny file-write*)', `(allow file-write* ${rw.map((p) => `(subpath ${sbq(p)})`).join(' ')} (subpath "/dev"))`,
|
|
1166
|
+
'(deny file-read-data (subpath "/"))',
|
|
1167
|
+
`(allow file-read-data (literal "/") ${[spec.cwd, ...rw].map((p) => `(literal ${sbq(real(p))})`).join(' ')} (subpath "/usr") (subpath "/System") (subpath "/Library") (subpath "/private/var/db") (subpath "/dev") (subpath "/nix/store") ${ro.map((p) => `(subpath ${sbq(p)})`).join(' ')} ${files.map((p) => `(literal ${sbq(p)})`).join(' ')} ${rw.map((p) => `(subpath ${sbq(p)})`).join(' ')})`,
|
|
1168
|
+
'(deny process-exec*)', `(allow process-exec (literal ${sbq(exec)}) (subpath "/nix/store"))`,
|
|
1169
|
+
].join('\n');
|
|
1170
|
+
return { file: sb.path, args: ['-p', profile, exec, ...spec.args], profile };
|
|
1171
|
+
}
|
|
1172
|
+
if (sb.backend === 'bwrap') {
|
|
1173
|
+
const a = ['--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp'];
|
|
1174
|
+
for (const p of ['/usr', '/lib', '/lib64', '/bin', '/etc/ssl', '/nix/store']) a.push('--ro-bind-try', p, p);
|
|
1175
|
+
for (const p of ro) a.push('--ro-bind', p, p);
|
|
1176
|
+
for (const p of files) a.push('--ro-bind', p, p);
|
|
1177
|
+
for (const p of rw) a.push('--bind', p, p);
|
|
1178
|
+
a.push('--chdir', spec.cwd, exec, ...spec.args);
|
|
1179
|
+
return { file: sb.path, args: a, profile: null };
|
|
1180
|
+
}
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ── process supervisor ───────────────────────────────────────────────────────
|
|
1185
|
+
function rssOfGroup(pgid) {
|
|
1186
|
+
const r = (0,external_node_child_process_.spawnSync)('/bin/ps', ['-o', 'rss=', '-g', String(pgid)], { encoding: 'utf8', timeout: 2000 });
|
|
1187
|
+
if (r.status !== 0) return 0;
|
|
1188
|
+
return r.stdout.split('\n').map((x) => parseInt(x, 10)).filter(Number.isFinite).reduce((a, b) => a + b, 0) / 1024;
|
|
1189
|
+
}
|
|
1190
|
+
function killGroup(pid, sig) { try { process.kill(-pid, sig); } catch { try { process.kill(pid, sig); } catch { /* gone */ } } }
|
|
1191
|
+
|
|
1192
|
+
/** Run a command to completion under a deadline, an output cap and a memory ceiling. Never throws. */
|
|
1193
|
+
function supervise(file, args, { env, cwd, deadlineMs, maxOutputBytes, maxRssMb, killGraceMs = 1500 }) {
|
|
1194
|
+
return new Promise((resolveP) => {
|
|
1195
|
+
const t0 = Date.now();
|
|
1196
|
+
let child;
|
|
1197
|
+
try { child = (0,external_node_child_process_.spawn)(file, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
1198
|
+
catch (e) { return resolveP({ status: 'failed', reason: `could not start: ${e.message}`, stdout: '', stderr: '', exitCode: null, signal: null, ms: 0 }); }
|
|
1199
|
+
let out = ''; let err = ''; let bytes = 0; let state = null; let done = false;
|
|
1200
|
+
const finish = (extra = {}) => { if (done) return; done = true; clearInterval(poll); clearTimeout(dl); resolveP({ stdout: out, stderr: err.slice(0, 4000), ms: Date.now() - t0, ...extra }); };
|
|
1201
|
+
const stop = (why) => { if (state) return; state = why; killGroup(child.pid, 'SIGTERM'); setTimeout(() => killGroup(child.pid, 'SIGKILL'), killGraceMs).unref(); };
|
|
1202
|
+
child.stdout.on('data', (d) => { bytes += d.length; if (bytes > maxOutputBytes) { stop('output-limit'); return; } out += d.toString('utf8'); });
|
|
1203
|
+
child.stderr.on('data', (d) => { if (err.length < 8000) err += d.toString('utf8'); });
|
|
1204
|
+
const dl = setTimeout(() => stop('timed-out'), deadlineMs);
|
|
1205
|
+
const poll = setInterval(() => { if (!state && maxRssMb && rssOfGroup(child.pid) > maxRssMb) stop('resource-limit'); }, 250);
|
|
1206
|
+
child.on('error', (e) => finish({ status: 'failed', reason: e.message, exitCode: null, signal: null }));
|
|
1207
|
+
child.on('close', (code, signal) => {
|
|
1208
|
+
killGroup(child.pid, 'SIGKILL'); // reap any descendants
|
|
1209
|
+
finish({ status: state || (code === 0 ? 'ok' : 'failed'), exitCode: code, signal, reason: state ? `${state}${state === 'timed-out' ? ` after ${deadlineMs} ms` : ''}` : (code === 0 ? null : `exit ${code}`) });
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const cleanEnv = (work, extra = {}) => ({ PATH: CLEAN_PATH, HOME: work, TMPDIR: work, LANG: 'C', LC_ALL: 'C', ...extra });
|
|
1215
|
+
|
|
1216
|
+
// ── 2. probe ─────────────────────────────────────────────────────────────────
|
|
1217
|
+
const PROBE_SOURCE = `
|
|
1218
|
+
const fs=require('fs'),net=require('net'),cp=require('child_process');
|
|
1219
|
+
const [allowedFile,outsideFile,outsideWrite,okWrite,port,sock]=process.argv.slice(2);
|
|
1220
|
+
const r={};const t=(k,f)=>{try{r[k]=f()}catch(e){r[k]='refused:'+(e.code||'error')}};
|
|
1221
|
+
t('readAllowed',()=>fs.readFileSync(allowedFile,'utf8').trim());
|
|
1222
|
+
t('readOutside',()=>{fs.readFileSync(outsideFile,'utf8');return 'READ'});
|
|
1223
|
+
t('readHostFile',()=>{fs.readFileSync('/etc/hosts','utf8');return 'READ'});
|
|
1224
|
+
t('writeOutside',()=>{fs.writeFileSync(outsideWrite,'x');return 'WROTE'});
|
|
1225
|
+
t('writeScratch',()=>{fs.writeFileSync(okWrite,'x');return 'wrote'});
|
|
1226
|
+
t('spawn',()=>{cp.execFileSync('/bin/echo',['x']);return 'SPAWNED'});
|
|
1227
|
+
t('credentialEnv',()=>process.env.PROBE_CREDENTIAL||'absent');
|
|
1228
|
+
let pending=2;const fin=()=>{if(--pending===0){console.log(JSON.stringify(r));process.exit(0)}};
|
|
1229
|
+
const tcp=net.connect(Number(port),'127.0.0.1');tcp.on('connect',()=>{r.network='CONNECTED';tcp.destroy();fin()});tcp.on('error',(e)=>{r.network='refused:'+e.code;fin()});
|
|
1230
|
+
const un=net.connect(sock);un.on('connect',()=>{r.daemonSocket='CONNECTED';un.destroy();fin()});un.on('error',(e)=>{r.daemonSocket='refused:'+e.code;fin()});
|
|
1231
|
+
setTimeout(()=>{r.timeout=true;console.log(JSON.stringify(r));process.exit(0)},8000);
|
|
1232
|
+
`;
|
|
1233
|
+
|
|
1234
|
+
/** server.listen that settles: it resolves when listening and rejects on the server's error (a bare callback never fires on failure). */
|
|
1235
|
+
function listenOrReject(server, ...args) {
|
|
1236
|
+
return new Promise((resolve, reject) => {
|
|
1237
|
+
const onError = (e) => reject(e);
|
|
1238
|
+
server.once('error', onError);
|
|
1239
|
+
server.listen(...args, () => { server.off('error', onError); resolve(); });
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Prove the isolation of a backend with real attempts, independent of any evaluator flag. The result is
|
|
1245
|
+
* `verified` only if the one permitted read worked, the scratch write worked and every other attempt was refused.
|
|
1246
|
+
*/
|
|
1247
|
+
async function probeSandbox(sb, opts = {}) {
|
|
1248
|
+
if (!sb || !sb.available) return { verified: false, backend: sb ? sb.backend : 'none', reason: (sb && sb.reason) || 'no sandbox', attempts: {} };
|
|
1249
|
+
const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
|
|
1250
|
+
const base = (0,external_node_fs_.mkdtempSync)((0,external_node_path_.join)((0,external_node_os_.tmpdir)(), 'nix-eval-probe-'));
|
|
1251
|
+
const allowed = (0,external_node_path_.join)(base, 'ro'); const scratch = (0,external_node_path_.join)(base, 'rw'); const outside = (0,external_node_path_.join)(base, 'outside');
|
|
1252
|
+
for (const d of [allowed, scratch, outside]) (0,external_node_fs_.mkdirSync)(d);
|
|
1253
|
+
(0,external_node_fs_.writeFileSync)((0,external_node_path_.join)(allowed, 'a.txt'), 'allowed'); (0,external_node_fs_.writeFileSync)((0,external_node_path_.join)(outside, 'secret.txt'), 'secret');
|
|
1254
|
+
const script = (0,external_node_path_.join)(base, 'probe.cjs'); (0,external_node_fs_.writeFileSync)(script, PROBE_SOURCE);
|
|
1255
|
+
let sockPath = (0,external_node_path_.join)(base, 'daemon.sock');
|
|
1256
|
+
let sockDir = null;
|
|
1257
|
+
let connections = 0;
|
|
1258
|
+
const tcp = (0,external_node_net_.createServer)((s) => { connections++; s.destroy(); });
|
|
1259
|
+
let uds = (0,external_node_net_.createServer)((s) => { connections++; s.destroy(); });
|
|
1260
|
+
try {
|
|
1261
|
+
await listenOrReject(tcp, 0, '127.0.0.1');
|
|
1262
|
+
try {
|
|
1263
|
+
await listenOrReject(uds, sockPath);
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
// A unix socket path is limited to about 104 bytes, and a temporary directory can easily be longer than that (macOS, CI runners).
|
|
1266
|
+
// Without an error handler the listen call never completed and the whole evaluation hung. Retry in a short directory; the probe
|
|
1267
|
+
// proves that the sandboxed process is refused, which does not depend on where the socket lives.
|
|
1268
|
+
if (!['EINVAL', 'ENAMETOOLONG'].includes(e && e.code) || !(0,external_node_fs_.existsSync)('/tmp')) throw e;
|
|
1269
|
+
uds.close();
|
|
1270
|
+
uds = (0,external_node_net_.createServer)((s) => { connections++; s.destroy(); });
|
|
1271
|
+
sockDir = (0,external_node_fs_.mkdtempSync)('/tmp/nep-');
|
|
1272
|
+
sockPath = (0,external_node_path_.join)(sockDir, 'd.sock');
|
|
1273
|
+
await listenOrReject(uds, sockPath);
|
|
1274
|
+
}
|
|
1275
|
+
} catch (e) {
|
|
1276
|
+
tcp.close(); uds.close(); (0,external_node_fs_.rmSync)(base, { recursive: true, force: true });
|
|
1277
|
+
if (sockDir) (0,external_node_fs_.rmSync)(sockDir, { recursive: true, force: true });
|
|
1278
|
+
return { verified: false, backend: sb.backend, reason: `the probe could not create its listeners (${(e && e.code) || 'error'}): isolation is not verified`, attempts: {} };
|
|
1279
|
+
}
|
|
1280
|
+
const port = tcp.address().port;
|
|
1281
|
+
try {
|
|
1282
|
+
const exe = opts.nodePath || process.execPath;
|
|
1283
|
+
const cmd = sandboxCommand(sb, { execFile: exe, args: [script, (0,external_node_path_.join)(allowed, 'a.txt'), (0,external_node_path_.join)(outside, 'secret.txt'), (0,external_node_path_.join)(outside, 'w.txt'), (0,external_node_path_.join)(scratch, 'w.txt'), String(port), sockPath], readOnly: [allowed, (0,external_node_path_.dirname)((0,external_node_path_.dirname)(real(exe)))], readFiles: [script], writable: [scratch], cwd: scratch });
|
|
1284
|
+
const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(scratch), cwd: scratch, deadlineMs: limits.probeDeadlineMs, maxOutputBytes: 64 * 1024, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
1285
|
+
let attempts = null;
|
|
1286
|
+
try { attempts = JSON.parse(r.stdout.trim()); } catch { /* unparseable: not verified */ }
|
|
1287
|
+
if (!attempts) return { verified: false, backend: sb.backend, reason: `the probe did not complete inside the sandbox (${r.reason || r.stderr.slice(0, 120) || 'no output'})`, attempts: {} };
|
|
1288
|
+
const insufficient = [];
|
|
1289
|
+
if (attempts.readAllowed !== 'allowed') insufficient.push('the allowlisted read failed: the sandbox is unusable');
|
|
1290
|
+
if (attempts.writeScratch !== 'wrote') insufficient.push('the scratch write failed: the sandbox is unusable');
|
|
1291
|
+
if (attempts.readOutside === 'READ') insufficient.push('a file outside the allowlist could be read');
|
|
1292
|
+
if (attempts.readHostFile === 'READ') insufficient.push('a host file (/etc/hosts) could be read');
|
|
1293
|
+
if (attempts.writeOutside === 'WROTE') insufficient.push('a file outside the scratch directory could be written');
|
|
1294
|
+
if (attempts.network === 'CONNECTED') insufficient.push('a network connection succeeded');
|
|
1295
|
+
if (attempts.daemonSocket === 'CONNECTED') insufficient.push('a unix daemon socket could be reached');
|
|
1296
|
+
if (attempts.spawn === 'SPAWNED') insufficient.push('a native process could be spawned');
|
|
1297
|
+
if (attempts.credentialEnv && attempts.credentialEnv !== 'absent') insufficient.push('a credential environment variable was visible');
|
|
1298
|
+
if (attempts.timeout) insufficient.push('the probe timed out');
|
|
1299
|
+
return { verified: insufficient.length === 0 && connections === 0, backend: sb.backend, reason: insufficient.length ? insufficient.join('; ') : (connections ? 'the probe listeners saw a connection' : null), attempts, listenerConnections: connections, insufficient };
|
|
1300
|
+
} finally { tcp.close(); uds.close(); (0,external_node_fs_.rmSync)(base, { recursive: true, force: true }); if (sockDir) (0,external_node_fs_.rmSync)(sockDir, { recursive: true, force: true }); }
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// ── 3. evaluator feature detection ───────────────────────────────────────────
|
|
1304
|
+
/** @param {{file:string, prefixArgs?:string[]}} nix the evaluator command (a stand-in in tests) */
|
|
1305
|
+
async function detectEvaluator(nix, sb, ctx) {
|
|
1306
|
+
const run = async (args) => {
|
|
1307
|
+
const cmd = sandboxCommand(sb, { execFile: nix.file, args: [...(nix.prefixArgs || []), ...args], readOnly: ctx.readOnly, readFiles: [...(ctx.readFiles || []), ...(nix.prefixArgs || []).filter((a) => !a.startsWith('-'))], writable: [ctx.work], cwd: ctx.work });
|
|
1308
|
+
return supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work), cwd: ctx.work, deadlineMs: 10_000, maxOutputBytes: 64 * 1024, maxRssMb: ctx.limits.maxRssMb, killGraceMs: ctx.limits.killGraceMs });
|
|
1309
|
+
};
|
|
1310
|
+
const v = await run(['--version']);
|
|
1311
|
+
const ver = (/(\d+\.\d+(?:\.\d+)?)/.exec(v.stdout) || [])[1] || null;
|
|
1312
|
+
if (v.status !== 'ok' || !ver) return { ok: false, reason: `the evaluator did not report a version (${v.reason || 'no output'})`, version: null, missing: REQUIRED_EVAL_FLAGS.slice() };
|
|
1313
|
+
const h = await run(['eval', '--help']);
|
|
1314
|
+
const text = `${h.stdout}\n${h.stderr}`;
|
|
1315
|
+
const missing = REQUIRED_EVAL_FLAGS.filter((f) => !text.includes(f));
|
|
1316
|
+
return { ok: missing.length === 0, version: ver, missing, reason: missing.length ? `the evaluator lacks required safety flag(s): ${missing.join(', ')}` : null };
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// ── target selection and argv ────────────────────────────────────────────────
|
|
1320
|
+
/** A flake output attribute, validated. Anything outside a plain attribute path is refused. */
|
|
1321
|
+
function selectTarget({ root, attribute, system = null } = {}) {
|
|
1322
|
+
if (typeof root !== 'string' || !root.startsWith('/')) return { ok: false, reason: 'the project root must be an absolute path' };
|
|
1323
|
+
if (typeof attribute !== 'string' || !ATTR_RE.test(attribute) || attribute.length > 200) return { ok: false, reason: `"${String(attribute).slice(0, 40)}" is not a plain flake attribute path` };
|
|
1324
|
+
if (/\.\./.test(attribute)) return { ok: false, reason: 'attribute paths may not contain ..' };
|
|
1325
|
+
return { ok: true, installable: `path:${root}#${attribute}`, root, attribute, system };
|
|
1326
|
+
}
|
|
1327
|
+
function buildEvalArgs(target) {
|
|
1328
|
+
const opts = Object.entries(SAFETY_OPTIONS).flatMap(([k, v]) => ['--option', k, v]);
|
|
1329
|
+
return ['eval', '--offline', '--no-update-lock-file', '--no-write-lock-file', '--pure-eval', '--json', ...opts, target.installable];
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// ── 4. run ───────────────────────────────────────────────────────────────────
|
|
1333
|
+
const IMPORT_FALLBACK = Object.freeze({
|
|
1334
|
+
summary: 'Evaluation did not run or did not finish. Static findings are unaffected. To supply the data yourself, generate an export on a trusted machine and pass it in:',
|
|
1335
|
+
steps: ['nix path-info --json --recursive <installable> > pathinfo.json', 'nix derivation show --recursive <installable> > drvshow.json', 'Provide both with a provenance envelope (tool, command, target system and installable, flake.lock sha256, generation time); see docs/guides/nix.md'],
|
|
1336
|
+
schemas: ['nix-path-info-json', 'nix-derivation-show-json', 'nix-store-query-text'],
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* @param {{nix:{file:string,prefixArgs?:string[]}, root:string, attribute:string, system?:string, limits?:object,
|
|
1341
|
+
* sandbox?:object, extraReadOnly?:string[], probe?:Function, env?:object}} o
|
|
1342
|
+
* @returns {Promise<{status:'ok'|'unsupported'|'blocked'|'timed-out'|'output-limit'|'resource-limit'|'failed', projectCodeEvaluated:boolean, ...}>}
|
|
1343
|
+
*/
|
|
1344
|
+
async function runIsolatedEval(o) {
|
|
1345
|
+
const limits = { ...DEFAULT_LIMITS, ...(o.limits || {}) };
|
|
1346
|
+
const keep = { staticFindingsRetained: true, fallback: IMPORT_FALLBACK, evaluation: 'opt-in' };
|
|
1347
|
+
const stopBefore = (status, reason, extra = {}) => ({ status, reason, projectCodeEvaluated: false, ...keep, ...extra });
|
|
1348
|
+
const target = selectTarget({ root: o.root, attribute: o.attribute, system: o.system });
|
|
1349
|
+
if (!target.ok) return stopBefore('blocked', target.reason);
|
|
1350
|
+
const sb = o.sandbox || detectSandbox();
|
|
1351
|
+
if (!sb.available) return stopBefore('unsupported', `no isolation is available (${sb.reason}); the project's Nix code was NOT evaluated`, { sandbox: sb });
|
|
1352
|
+
const probe = await (o.probe || probeSandbox)(sb, { limits });
|
|
1353
|
+
if (!probe.verified) return stopBefore('blocked', `the sandbox did not prove its isolation, so the project's Nix code was NOT evaluated: ${probe.reason}`, { sandbox: sb, probe });
|
|
1354
|
+
const work = (0,external_node_fs_.mkdtempSync)((0,external_node_path_.join)((0,external_node_os_.tmpdir)(), 'nix-eval-work-'));
|
|
1355
|
+
try {
|
|
1356
|
+
const root = real(o.root);
|
|
1357
|
+
const nixFiles = [nix_file(o.nix), ...(o.nix.prefixArgs || []).filter((a) => a && !a.startsWith('-')).map(real)];
|
|
1358
|
+
const ctx = { work: real(work), readOnly: [root, (0,external_node_path_.dirname)((0,external_node_path_.dirname)(real(o.nix.file))), ...(o.extraReadOnly || [])], readFiles: nixFiles, limits };
|
|
1359
|
+
const feat = await detectEvaluator(o.nix, sb, ctx);
|
|
1360
|
+
if (!feat.ok) return stopBefore('unsupported', `${feat.reason}; the project's Nix code was NOT evaluated`, { sandbox: sb, probe: { verified: true }, evaluator: feat });
|
|
1361
|
+
const args = [...(o.nix.prefixArgs || []), ...buildEvalArgs(target)];
|
|
1362
|
+
const cmd = sandboxCommand(sb, { execFile: o.nix.file, args, readOnly: ctx.readOnly, readFiles: nixFiles, writable: [ctx.work], cwd: ctx.work });
|
|
1363
|
+
const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work, o.env || {}), cwd: ctx.work, deadlineMs: limits.deadlineMs, maxOutputBytes: limits.maxOutputBytes, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
1364
|
+
const base = { ...keep, projectCodeEvaluated: true, sandbox: { backend: sb.backend }, evaluator: { version: feat.version }, ms: r.ms, target: { attribute: target.attribute, system: target.system, installable: target.installable } };
|
|
1365
|
+
if (r.status !== 'ok') return { ...base, status: r.status === 'failed' ? 'failed' : r.status, reason: r.reason || r.stderr.slice(0, 200) };
|
|
1366
|
+
let data;
|
|
1367
|
+
try { data = JSON.parse(r.stdout); } catch { return { ...base, status: 'failed', reason: 'the evaluator output was not JSON' }; }
|
|
1368
|
+
const digest = (0,external_node_crypto_.createHash)('sha256').update(r.stdout).digest('hex');
|
|
1369
|
+
return { ...base, status: 'ok', reason: null, export: { schema: 'nix-eval-attrs-json', provenance: { tool: 'nix', toolVersion: feat.version, command: `nix ${buildEvalArgs(target).join(' ')}`, target: { system: target.system, installable: target.installable, attribute: target.attribute }, generatedAt: new Date().toISOString(), isolation: sb.backend, outputSha256: digest }, data }, outputSha256: digest };
|
|
1370
|
+
} finally { (0,external_node_fs_.rmSync)(work, { recursive: true, force: true }); }
|
|
1371
|
+
}
|
|
1372
|
+
const nix_file = (n) => real(n.file);
|
|
1373
|
+
|
|
1374
|
+
/** Evaluation health is its OWN state: it never replaces, empties or downgrades the static scan. */
|
|
1375
|
+
function mergeEvaluationHealth(scanHealth, result) {
|
|
1376
|
+
const h = scanHealth && typeof scanHealth === 'object' ? { ...scanHealth } : {};
|
|
1377
|
+
const requested = !!result;
|
|
1378
|
+
h.evaluation = requested
|
|
1379
|
+
? { requested: true, status: result.status, ran: result.projectCodeEvaluated === true, reason: result.reason || null, ms: result.ms ?? null, fallback: result.status === 'ok' ? null : result.fallback || IMPORT_FALLBACK }
|
|
1380
|
+
: { requested: false, status: 'not-requested', ran: false, reason: 'evaluation is opt-in; the scan is static', fallback: null };
|
|
1381
|
+
h.staticFindingsRetained = true;
|
|
1382
|
+
return h;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
;// CONCATENATED MODULE: ./src/language/resolved-pass.js
|
|
1388
|
+
// Resolved dependency data in the scan path (QA-005.AC04): explicit exports a user or tool wrote on purpose become part of a normal scan,
|
|
1389
|
+
// instead of being reachable only from a unit test.
|
|
1390
|
+
//
|
|
1391
|
+
// Haskell dist-newstyle/cache/plan.json and .stack-work/dependencies.json -> a resolved graph (transitive packages, exact
|
|
1392
|
+
// versions, scopes) joined to the declared manifests; stale data is used for nothing and disclosed.
|
|
1393
|
+
// Nix nix-export.json (and .direnv/nix-export.json) -> an imported closure, matched against an advisory
|
|
1394
|
+
// snapshot with upstream-identity and patch-evidence rules; no feed means "unknown", never "clean".
|
|
1395
|
+
// Eval AGENTIC_SECURITY_NIX_EVAL=1 + AGENTIC_SECURITY_NIX_TARGET=<attr> -> an isolated, opt-in evaluation whose state is its
|
|
1396
|
+
// own scan-health field. A scan that did not select it never runs a Nix evaluator.
|
|
1397
|
+
//
|
|
1398
|
+
// Nothing here fetches, builds, evaluates or runs a project's code unless the evaluation is selected AND the sandbox proves its isolation.
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
const RESOLVED_PASS_VERSION = 'resolved-pass/1';
|
|
1412
|
+
|
|
1413
|
+
const PLAN = /(?:^|\/)dist-newstyle\/cache\/plan\.json$/;
|
|
1414
|
+
const STACK_EXPORT = /(?:^|\/)\.stack-work\/dependencies\.json$/;
|
|
1415
|
+
const NIX_EXPORT = /(?:^|\/)nix-export\.json$/;
|
|
1416
|
+
const ACTIONABLE = new Set(['affected', 'possibly-affected', 'candidate', 'backported-verified']);
|
|
1417
|
+
|
|
1418
|
+
const memo = new WeakMap();
|
|
1419
|
+
const memoized = (files, key, make) => { let m = memo.get(files); if (!m) { m = new Map(); memo.set(files, m); } if (!m.has(key)) m.set(key, make()); return m.get(key); };
|
|
1420
|
+
|
|
1421
|
+
function parseJson(text) { try { return { ok: true, value: JSON.parse(text) }; } catch (e) { return { ok: false, reason: String((e && e.message) || e).slice(0, 120) }; } }
|
|
1422
|
+
|
|
1423
|
+
/** The resolved Haskell graph for a file set, or null when no explicit export is present. Cached per file set. */
|
|
1424
|
+
function resolvedHaskellGraph(files) {
|
|
1425
|
+
return memoized(files, 'hs-graph', () => {
|
|
1426
|
+
const planFile = Object.keys(files).find((p) => PLAN.test(p));
|
|
1427
|
+
const stackFile = !planFile ? Object.keys(files).find((p) => STACK_EXPORT.test(p)) : null;
|
|
1428
|
+
const src = planFile || stackFile;
|
|
1429
|
+
if (!src) return null;
|
|
1430
|
+
const mf = (0,haskell_supply/* manifestFiles */.TY)(files);
|
|
1431
|
+
const manifests = mf.length ? (0,haskell_manifests/* analyzeHaskellManifests */.JS)(mf) : null;
|
|
1432
|
+
const parsed = parseJson(files[src]);
|
|
1433
|
+
if (!parsed.ok) return { file: src, graph: { source: planFile ? 'cabal-plan' : 'stack-export', graphAvailable: false, units: null, edges: null, freshness: { status: 'unverified', reasons: [] }, closure: { complete: false, reason: 'malformed_resolved_data' }, gaps: [{ kind: planFile ? 'malformed_plan' : 'malformed_stack_export', detail: `${src} is not valid JSON: ${parsed.reason}` }], declared: [] }, manifests };
|
|
1434
|
+
const graph = (0,haskell_resolved_graph/* buildResolvedGraph */.xU)({ manifests, ...(planFile ? { plan: parsed.value } : { stackExport: parsed.value }), expected: {} });
|
|
1435
|
+
return { file: src, graph, manifests };
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Components for the resolved graph: every Hackage unit with an exact version, its scope and where it came from. Only a graph that is
|
|
1441
|
+
* not stale contributes; a stale one is returned as a gap (never as components), and a partial one says it is partial.
|
|
1442
|
+
*/
|
|
1443
|
+
function resolvedHackageComponents(files) {
|
|
1444
|
+
const r = resolvedHaskellGraph(files);
|
|
1445
|
+
if (!r) return { components: [], gaps: [], summary: null };
|
|
1446
|
+
const g = r.graph;
|
|
1447
|
+
const gaps = (g.gaps || []).map((x) => ({ kind: `resolved-${x.kind}`, detail: x.detail || x.message || x.kind, file: r.file }));
|
|
1448
|
+
const summary = { file: r.file, source: g.source, graphAvailable: g.graphAvailable, freshness: g.freshness, closure: g.closure, units: g.units ? g.units.length : null, edges: g.edges ? g.edges.length : null };
|
|
1449
|
+
if (!g.graphAvailable || g.freshness.status === 'stale') return { components: [], gaps, summary };
|
|
1450
|
+
// The plan is a file in the scanned project. Where the project's own freeze file also pins a package, the two must agree: a plan that
|
|
1451
|
+
// contradicts the freeze is stale or forged, and contributes no versions.
|
|
1452
|
+
const frozen = new Map(((r.manifests && r.manifests.lockedPackages) || []).filter((l) => l.name && l.version).map((l) => [l.name, l.version]));
|
|
1453
|
+
const clashes = (g.units || []).filter((u) => u.name && u.version && frozen.has(u.name) && frozen.get(u.name) !== u.version).map((u) => `${u.name} ${u.version} (plan) vs ${frozen.get(u.name)} (freeze)`);
|
|
1454
|
+
if (clashes.length) { gaps.push({ kind: 'resolved-stale-plan-contradicts-freeze', detail: `the plan contradicts the freeze file (${clashes.slice(0, 3).join('; ')}): it is stale or forged, so no version was taken from it`, file: r.file }); return { components: [], gaps, summary }; }
|
|
1455
|
+
gaps.push({ kind: 'resolved-project-supplied-plan', detail: 'the plan is a file in the scanned project: it is checked against the declared bounds and the freeze file, but whoever controls the project controls its contents', file: r.file });
|
|
1456
|
+
const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
|
|
1457
|
+
const direct = new Set((g.declared || []).map((d) => d.name));
|
|
1458
|
+
const components = [];
|
|
1459
|
+
// Boot libraries are not dependencies to match, but the plan states the exact version the compiler provides: the compiler-advisory
|
|
1460
|
+
// grouping decides a boot-library advisory from it (and from the compiler-id) instead of from a declared range.
|
|
1461
|
+
const bootLibraries = (g.units || []).filter((u) => u.origin === 'compiler' && u.name && u.version).map((u) => ({ name: u.name, version: u.version, versionSource: { file: r.file, line: null } }));
|
|
1462
|
+
for (const u of g.units) {
|
|
1463
|
+
if (u.origin === 'local' || u.origin === 'compiler' || !u.name || !u.version) continue; // the project itself and boot libraries are not dependencies to match
|
|
1464
|
+
const scopes = Array.isArray(u.scopes) && u.scopes.length ? u.scopes : ['runtime'];
|
|
1465
|
+
const scope = scopes.includes('runtime') ? 'runtime' : scopes[0];
|
|
1466
|
+
components.push({ ecosystem: 'hackage', name: u.name, version: u.version, declaredRange: null, resolution: 'plan', scope, engineScope: SCOPE[scope] || 'required', target: null, componentKind: u.componentKind || null, manifest: r.file, line: null, ghcComponent: false, direct: direct.has(u.name), unitId: u.id, origin: u.origin, versionSource: { file: r.file, line: null } });
|
|
1467
|
+
}
|
|
1468
|
+
return { components, bootLibraries, compiler: g.compiler || null, gaps, summary };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// ── Nix closure ──────────────────────────────────────────────────────────────────
|
|
1472
|
+
function loadNixExports(files, env = process.env) {
|
|
1473
|
+
const exports = []; const sources = []; const problems = []; const trust = new Set();
|
|
1474
|
+
const take = (label, text, origin) => {
|
|
1475
|
+
sources.push(label);
|
|
1476
|
+
const parsed = parseJson(text);
|
|
1477
|
+
if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: label, detail: parsed.reason }); return; }
|
|
1478
|
+
const j = parsed.value;
|
|
1479
|
+
trust.add(origin);
|
|
1480
|
+
// NOTE: an `expected` block inside the file is IGNORED. The file is the thing under test, so it cannot also say what it should be
|
|
1481
|
+
// checked against; what an export must match is derived by the scanner (below) or comes from the operator.
|
|
1482
|
+
if (j && Array.isArray(j.exports)) exports.push(...j.exports.filter((e) => e && typeof e === 'object'));
|
|
1483
|
+
else if (j && typeof j === 'object' && j.schema && (j.data !== undefined || j.text !== undefined)) exports.push(j);
|
|
1484
|
+
else exports.push({ text });
|
|
1485
|
+
};
|
|
1486
|
+
if (env.AGENTIC_SECURITY_NIX_EXPORT) {
|
|
1487
|
+
try { take(env.AGENTIC_SECURITY_NIX_EXPORT, (0,external_node_fs_.readFileSync)(env.AGENTIC_SECURITY_NIX_EXPORT, 'utf8'), 'operator'); }
|
|
1488
|
+
catch (e) { sources.push(env.AGENTIC_SECURITY_NIX_EXPORT); problems.push({ kind: 'malformed-export', file: env.AGENTIC_SECURITY_NIX_EXPORT, detail: `unreadable: ${e.code || e.message}` }); }
|
|
1489
|
+
}
|
|
1490
|
+
for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) take(p, files[p], 'project-supplied');
|
|
1491
|
+
// What the export must have been made against: the real flake.lock of this project (a forged export cannot also forge that file's hash
|
|
1492
|
+
// without the lock it claims to describe being the lock that is here).
|
|
1493
|
+
const expected = {};
|
|
1494
|
+
const lockPath = Object.keys(files).filter((p) => /(^|\/)flake\.lock$/.test(p)).sort((a, b) => a.length - b.length)[0];
|
|
1495
|
+
if (lockPath) expected.flakeLockSha256 = (0,external_node_crypto_.createHash)('sha256').update(files[lockPath]).digest('hex');
|
|
1496
|
+
else if (sources.length) problems.push({ kind: 'export-unverified', detail: 'there is no flake.lock to bind the export to, so it cannot be shown to describe this project' });
|
|
1497
|
+
if (env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY) { try { expected.publicKeyPem = (0,external_node_fs_.readFileSync)(env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, 'utf8'); expected.requireSigned = true; } catch { problems.push({ kind: 'malformed-export', detail: 'the configured export public key is unreadable' }); } }
|
|
1498
|
+
return { exports, sources, problems, expected, trust: [...trust] };
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** Names of the Haskell packages inside an imported Nix closure (derivations whose env carries Haskell build attributes). */
|
|
1502
|
+
function nixHaskellPackageNames(files, opts = {}) {
|
|
1503
|
+
const c = nixClosureOf(files, opts);
|
|
1504
|
+
if (!c || (c.refused && c.refused.length)) return [];
|
|
1505
|
+
const nodes = (c.closure && c.closure.nodes) || [];
|
|
1506
|
+
return [...new Set(nodes.filter((n) => n.pname && n.deriver && c.drvEnv[n.deriver] && HASKELL_ENV_HINTS.some((k) => k in c.drvEnv[n.deriver])).map((n) => n.pname))];
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
|
|
1510
|
+
function nixClosureOf(files, { now = Date.now() } = {}) {
|
|
1511
|
+
return memoized(files, 'nix-closure', () => {
|
|
1512
|
+
const { exports, sources, problems, expected, trust } = loadNixExports(files);
|
|
1513
|
+
if (!sources.length) return null;
|
|
1514
|
+
const closure = importNixClosure({ exports, expected, now });
|
|
1515
|
+
const drvEnv = {};
|
|
1516
|
+
for (const ex of exports) {
|
|
1517
|
+
if (ex.schema !== 'nix-derivation-show-json') continue;
|
|
1518
|
+
let data = ex.data; if (data === undefined && typeof ex.text === 'string') { const p = parseJson(ex.text); data = p.ok ? p.value : null; }
|
|
1519
|
+
for (const [k, v] of Object.entries(data || {})) if (v && v.env) drvEnv[k] = v.env;
|
|
1520
|
+
}
|
|
1521
|
+
// A disclosure that says the export does not describe THIS project (another lock, another target, a bad or missing required
|
|
1522
|
+
// signature) means its contents are not evidence about this build: it is refused, not merely labelled.
|
|
1523
|
+
const REFUSE = new Set(['stale-export', 'foreign-target', 'foreign-revision', 'invalid-signature', 'unsigned']);
|
|
1524
|
+
const refused = (closure.disclosures || []).filter((d) => REFUSE.has(d.kind));
|
|
1525
|
+
return { closure, drvEnv, sources, problems, trust, refused };
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** Advisory records for Nix: a snapshot named by AGENTIC_SECURITY_NIX_ADVISORIES or kept in the project state directory. */
|
|
1530
|
+
function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
|
|
1531
|
+
const sel = (0,trusted_inputs/* resolveOperatorSnapshot */.Rn)({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
|
|
1532
|
+
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') : ''}` };
|
|
1533
|
+
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}` }; }
|
|
1534
|
+
if (snap && snap.schema === SNAPSHOT_SCHEMA) {
|
|
1535
|
+
// A snapshot the live feed wrote: its records must match their hashes, and it carries which CPE identities it actually read.
|
|
1536
|
+
const live = loadLiveSnapshot(snap);
|
|
1537
|
+
if (!live.ok) return { data: null, reason: `the live Nix advisory snapshot was refused: ${live.reason}` };
|
|
1538
|
+
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 };
|
|
1539
|
+
}
|
|
1540
|
+
const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
|
|
1541
|
+
return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Package metadata for the closure (meta.identifiers, knownVulnerabilities, license), from a file the OPERATOR names in
|
|
1546
|
+
* AGENTIC_SECURITY_NIX_META: either `{pname: meta}` or the `nix-env -qa --meta --json` shape (`{attr: {pname, meta}}`). It is never
|
|
1547
|
+
* read from the scanned project, because metadata decides which upstream identity a component is matched under.
|
|
1548
|
+
*/
|
|
1549
|
+
function configuredNixMeta(env = process.env) {
|
|
1550
|
+
const p = env.AGENTIC_SECURITY_NIX_META;
|
|
1551
|
+
if (!p) return { meta: {}, reason: null };
|
|
1552
|
+
let raw;
|
|
1553
|
+
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}` }; }
|
|
1554
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { meta: {}, reason: 'the Nix metadata file is not a JSON object' };
|
|
1555
|
+
const meta = {};
|
|
1556
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
1557
|
+
if (!v || typeof v !== 'object') continue;
|
|
1558
|
+
const m = v.meta && typeof v.meta === 'object' ? v.meta : v;
|
|
1559
|
+
const name = typeof v.pname === 'string' && v.pname ? v.pname : k.split('.').pop();
|
|
1560
|
+
if (/^[\w.+-]{1,100}$/.test(name) && !Object.prototype.hasOwnProperty.call(meta, name)) meta[name] = m;
|
|
1561
|
+
}
|
|
1562
|
+
return { meta, reason: null };
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* The async step before a scan's synchronous passes: when the live Nix feed is enabled, make sure the operator snapshot covers every
|
|
1567
|
+
* CPE identity the imported closure declares. Does nothing, and costs nothing, unless the feed is enabled. Never throws.
|
|
1568
|
+
*/
|
|
1569
|
+
async function prefetchNixAdvisoryFeed(files, { env = process.env, ...opts } = {}) {
|
|
1570
|
+
if (!liveFeedEnabled(env)) return null;
|
|
1571
|
+
try {
|
|
1572
|
+
const c = nixClosureOf(files, opts.now ? { now: opts.now } : {});
|
|
1573
|
+
if (!c || (c.refused && c.refused.length)) return null;
|
|
1574
|
+
const { cpes } = closureIdentities({ closure: c.closure, drvEnv: c.drvEnv, meta: configuredNixMeta(env).meta, overlays: overlayEvidence(files) });
|
|
1575
|
+
if (!cpes.length) return null;
|
|
1576
|
+
return await refreshNixAdvisories(cpes, { env, ...opts });
|
|
1577
|
+
} catch (e) { return { status: 'failed', detail: `the feed step failed: ${String((e && e.message) || e).slice(0, 120)}` }; }
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function nixLiveFeedNote(env) {
|
|
1581
|
+
const r = getLastRefresh();
|
|
1582
|
+
if (r && r.status !== 'disabled') return ` Live feed: ${r.status}, ${r.detail}.`;
|
|
1583
|
+
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).`;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** Closure statuses and findings, with every disclosure the import and the feed produced. */
|
|
1587
|
+
function analyzeNixClosure(files, opts = {}) {
|
|
1588
|
+
return memoized(files, 'nix-analysis', () => _analyzeNixClosure(files, opts));
|
|
1589
|
+
}
|
|
1590
|
+
function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = Date.now() } = {}) {
|
|
1591
|
+
const c = nixClosureOf(files, { now });
|
|
1592
|
+
if (!c) return null;
|
|
1593
|
+
const hackage = (0,haskell_supply.configuredAdvisoryDb)(scanRoot, env).db;
|
|
1594
|
+
const adv = configuredNixAdvisories(scanRoot, env, hackage);
|
|
1595
|
+
if (!adv.data) adv.reason = `${adv.reason}${nixLiveFeedNote(env)}`;
|
|
1596
|
+
const nm = configuredNixMeta(env);
|
|
1597
|
+
let usage = null; try { usage = (0,haskell_supply/* collectUsage */.c$)(files); } catch { usage = null; }
|
|
1598
|
+
let matched = null;
|
|
1599
|
+
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) }); }
|
|
1600
|
+
const gaps = [];
|
|
1601
|
+
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)' });
|
|
1602
|
+
for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
|
|
1603
|
+
for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
|
|
1604
|
+
for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
|
|
1605
|
+
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.` });
|
|
1606
|
+
if (nm.reason) gaps.push({ kind: 'closure-meta-unreadable', detail: nm.reason });
|
|
1607
|
+
const notCovered = ((matched && matched.statuses) || []).filter((x) => x.feedCoverage === 'incomplete');
|
|
1608
|
+
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.` });
|
|
1609
|
+
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`})` });
|
|
1610
|
+
const findings = ((matched && matched.findings) || []).filter((f) => ACTIONABLE.has(f.status)).map((f) => ({ ...f, file: c.sources[0], line: 1 }));
|
|
1611
|
+
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 };
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// ── isolated evaluation (opt in) ─────────────────────────────────────────────────
|
|
1615
|
+
/** Runs the isolated evaluation when AGENTIC_SECURITY_NIX_EVAL=1; otherwise null. Never throws; a failure is a recorded state. */
|
|
1616
|
+
async function runSelectedNixEval(root, env = process.env, overrides = {}) {
|
|
1617
|
+
if (env.AGENTIC_SECURITY_NIX_EVAL !== '1') return null;
|
|
1618
|
+
const attribute = env.AGENTIC_SECURITY_NIX_TARGET || '';
|
|
1619
|
+
const nixBin = overrides.nix || (which ? which('nix') : null);
|
|
1620
|
+
if (!attribute) return { status: 'blocked', reason: 'no evaluation target: set AGENTIC_SECURITY_NIX_TARGET to a flake output attribute (for example nixosConfigurations.host.config.system.build.toplevel.drvPath)', projectCodeEvaluated: false };
|
|
1621
|
+
if (!nixBin) return { status: 'unsupported', reason: 'the nix binary was not found on PATH; the project\'s Nix code was NOT evaluated', projectCodeEvaluated: false };
|
|
1622
|
+
try { return await runIsolatedEval({ nix: typeof nixBin === 'string' ? { file: nixBin } : nixBin, root, attribute, system: env.AGENTIC_SECURITY_NIX_SYSTEM || null, ...overrides }); }
|
|
1623
|
+
catch (e) { return { status: 'failed', reason: `the evaluation harness failed: ${String((e && e.message) || e).slice(0, 160)}`, projectCodeEvaluated: false }; }
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
/***/ })
|
|
1630
|
+
|
|
1631
|
+
};
|