@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 +47 -0
- package/bin/agentic-security.js +131 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1192 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +20 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +174 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +289 -0
- package/src/language/trusted-inputs.js +38 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
export const id = 8551;
|
|
2
|
+
export const ids = [8551];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 38551:
|
|
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
|
+
resolvedHackageComponents: () => (/* binding */ resolvedHackageComponents),
|
|
14
|
+
resolvedHaskellGraph: () => (/* binding */ resolvedHaskellGraph),
|
|
15
|
+
runSelectedNixEval: () => (/* binding */ runSelectedNixEval)
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// UNUSED EXPORTS: RESOLVED_PASS_VERSION, configuredNixAdvisories, mergeEvaluationHealth
|
|
19
|
+
|
|
20
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
21
|
+
var external_node_fs_ = __webpack_require__(73024);
|
|
22
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
23
|
+
var external_node_crypto_ = __webpack_require__(77598);
|
|
24
|
+
// EXTERNAL MODULE: ./src/language/trusted-inputs.js
|
|
25
|
+
var trusted_inputs = __webpack_require__(6342);
|
|
26
|
+
// EXTERNAL MODULE: ./src/language/haskell-manifests.js
|
|
27
|
+
var haskell_manifests = __webpack_require__(16522);
|
|
28
|
+
// EXTERNAL MODULE: ./src/language/haskell-resolved-graph.js
|
|
29
|
+
var haskell_resolved_graph = __webpack_require__(86359);
|
|
30
|
+
;// CONCATENATED MODULE: ./src/language/nix-closure.js
|
|
31
|
+
// Resolved derivation and closure inventory (NIX-008).
|
|
32
|
+
//
|
|
33
|
+
// Reads EXPLICITLY SUPPLIED, target-specific exports produced by supported Nix tools; it never runs nix,
|
|
34
|
+
// never discovers an executable and never touches the filesystem (this module imports no fs): store-looking
|
|
35
|
+
// paths are only strings to be validated and related to each other.
|
|
36
|
+
//
|
|
37
|
+
// Supported schemas (all documented in docs/guides/nix.md):
|
|
38
|
+
// nix-path-info-json `nix path-info --json --recursive <installable>`: the object form (keys are store
|
|
39
|
+
// paths, Nix >= 2.19) and the older array form ([{path, references, deriver, ...}])
|
|
40
|
+
// nix-derivation-show-json `nix derivation show --recursive <installable>`: object keyed by .drv path with
|
|
41
|
+
// outputs, inputSrcs, inputDrvs, env; also the wrapped {derivations, version} form
|
|
42
|
+
// whose inputs live under inputs.drvs / inputs.srcs and whose paths omit /nix/store/
|
|
43
|
+
// nix-store-query-text `nix-store --query --requisites <path>`: one store path per line (OUTPUT-ONLY
|
|
44
|
+
// identity: no deriver, hash or version, so it can never support an exact claim)
|
|
45
|
+
//
|
|
46
|
+
// Two different graphs come out and are never conflated: the RUNTIME closure (references of the target's
|
|
47
|
+
// output paths) and the BUILD graph (inputDrvs of its derivation). A node's scopes say which it is in.
|
|
48
|
+
// An exact claim is made only when every condition holds; each failed condition is a disclosure.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
const NIX_CLOSURE_VERSION = 'nix-closure/1';
|
|
53
|
+
const CLOSURE_BUDGETS = Object.freeze({ maxBytes: 64 * 1024 * 1024, maxNodes: 200_000, maxEdges: 1_000_000, maxEnvBytes: 1 << 20, maxExports: 16 });
|
|
54
|
+
const SUPPORTED_TOOLS = new Set(['nix', 'nix-store']);
|
|
55
|
+
const SCHEMA_COMMANDS = {
|
|
56
|
+
'nix-path-info-json': /^nix(?:\s+--\S+)*\s+path-info\b.*--json\b|^nix(?:\s+--\S+)*\s+path-info\b.*--recursive\b/,
|
|
57
|
+
'nix-derivation-show-json': /^nix(?:\s+--\S+)*\s+(?:derivation\s+show|show-derivation)\b/,
|
|
58
|
+
'nix-store-query-text': /^nix-store\b.*(?:--query|-q)\b.*(?:--requisites|-R|--references|--tree)\b|^nix-store\s+-qR\b/,
|
|
59
|
+
};
|
|
60
|
+
const STORE = '/nix/store/';
|
|
61
|
+
const BASE32 = '0123456789abcdfghijklmnpqrsvwxyz';
|
|
62
|
+
const STORE_PATH = new RegExp(`^/nix/store/([${BASE32}]{32})-([^/\\x00\\s]+)$`);
|
|
63
|
+
const OUTPUT_SUFFIX = /-(?:dev|bin|lib|out|man|doc|info|devdoc|debug|static|dist|fakeroot|headers|include|terminfo)$/;
|
|
64
|
+
const ROLE_OF_ENV = [
|
|
65
|
+
['nativeCheckInputs', 'test'], ['checkInputs', 'test'], ['installCheckInputs', 'test'],
|
|
66
|
+
['nativeBuildInputs', 'build'], ['depsBuildBuild', 'build'], ['depsBuildTarget', 'build'], ['depsBuildBuildPropagated', 'build'], ['propagatedNativeBuildInputs', 'build'],
|
|
67
|
+
['buildInputs', 'build+runtime'], ['propagatedBuildInputs', 'build+runtime'], ['depsHostHost', 'build+runtime'], ['depsTargetTarget', 'build+runtime'], ['depsHostHostPropagated', 'build+runtime'],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** Validate and split a store path. Returns null for anything that is not exactly /nix/store/<hash>-<name>. */
|
|
71
|
+
function parseStorePath(p) {
|
|
72
|
+
if (typeof p !== 'string' || p.length > 1024) return null;
|
|
73
|
+
const m = STORE_PATH.exec(p);
|
|
74
|
+
if (!m || m[2] === '.' || m[2] === '..' || m[2].includes('..')) return null;
|
|
75
|
+
return { path: p, hash: m[1], name: m[2] };
|
|
76
|
+
}
|
|
77
|
+
const normalizePath = (p) => (typeof p === 'string' && /^[0-9a-df-np-sv-z]{32}-/.test(p) ? STORE + p : p);
|
|
78
|
+
|
|
79
|
+
/** Heuristic only: nixpkgs naming is pname-version where the version starts at the first "-<digit>". */
|
|
80
|
+
function inferNameVersion(name, drvEnv) {
|
|
81
|
+
if (drvEnv && typeof drvEnv.pname === 'string' && typeof drvEnv.version === 'string') return { pname: drvEnv.pname, version: drvEnv.version, authority: 'derivation-env' };
|
|
82
|
+
if (drvEnv && typeof drvEnv.name === 'string') { const m = /^(.+?)-(\d.*)$/.exec(drvEnv.name); if (m) return { pname: m[1], version: m[2], authority: 'derivation-env' }; }
|
|
83
|
+
const base = String(name || '').replace(/\.drv$/, '').replace(OUTPUT_SUFFIX, '');
|
|
84
|
+
const m = /^(.+?)-(\d.*)$/.exec(base);
|
|
85
|
+
return m ? { pname: m[1], version: m[2], authority: 'inferred-from-store-path' } : { pname: base || null, version: null, authority: 'inferred-from-store-path' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function detectSchema(parsed, text) {
|
|
89
|
+
if (typeof text === 'string' && parsed === undefined) {
|
|
90
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
91
|
+
return lines.length && lines.every((l) => l.startsWith('/')) ? 'nix-store-query-text' : null;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(parsed)) return parsed.every((x) => x && typeof x === 'object' && typeof x.path === 'string') ? 'nix-path-info-json' : null;
|
|
94
|
+
if (parsed && typeof parsed === 'object') {
|
|
95
|
+
if (parsed.derivations && typeof parsed.derivations === 'object') return 'nix-derivation-show-json';
|
|
96
|
+
const ks = Object.keys(parsed);
|
|
97
|
+
if (!ks.length) return null;
|
|
98
|
+
if (ks.every((k) => /\.drv$/.test(k))) return 'nix-derivation-show-json';
|
|
99
|
+
if (ks.every((k) => /^(?:\/nix\/store\/)?[0-9a-df-np-sv-z]{32}-/.test(k))) return 'nix-path-info-json';
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {{exports: Array<{schema?:string, text?:string, data?:any, provenance?:object, signature?:string}>,
|
|
108
|
+
* expected?: {system?:string, installable?:string, revision?:object, flakeLockSha256?:string, maxAgeDays?:number, inputsChangedAtMs?:number, roots?:string[], requireSigned?:boolean, publicKeyPem?:string},
|
|
109
|
+
* now?: number, budgets?: object}} opts
|
|
110
|
+
*/
|
|
111
|
+
function importNixClosure(opts = {}) {
|
|
112
|
+
const b = { ...CLOSURE_BUDGETS, ...(opts.budgets || {}) };
|
|
113
|
+
const now = opts.now ?? Date.now();
|
|
114
|
+
const expected = opts.expected || {};
|
|
115
|
+
const dis = []; const diag = [];
|
|
116
|
+
const add = (kind, detail, extra = {}) => dis.push({ kind, detail, ...extra });
|
|
117
|
+
const out = { version: NIX_CLOSURE_VERSION, status: 'ok', nodes: [], edges: [], disclosures: dis, diagnostics: diag, claims: null, provenance: [], roots: [], coverage: {} };
|
|
118
|
+
const exps = (opts.exports || []).slice(0, b.maxExports);
|
|
119
|
+
if ((opts.exports || []).length > b.maxExports) add('truncated', `only the first ${b.maxExports} exports were read`);
|
|
120
|
+
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; }
|
|
121
|
+
|
|
122
|
+
// ── parse each export ──
|
|
123
|
+
const pathInfo = new Map(); // path -> {references[], deriver, narHash, narSize, valid}
|
|
124
|
+
const drvs = new Map(); // drv path -> entry
|
|
125
|
+
const queryPaths = new Set();
|
|
126
|
+
const provenance = [];
|
|
127
|
+
let truncated = false; let malformed = 0;
|
|
128
|
+
|
|
129
|
+
for (const [idx, ex] of exps.entries()) {
|
|
130
|
+
const label = `export[${idx}]`;
|
|
131
|
+
let parsed; let text = typeof ex.text === 'string' ? ex.text : null;
|
|
132
|
+
if (text !== null && text.length > b.maxBytes) { add('truncated', `${label} is ${text.length} bytes (limit ${b.maxBytes}); not read`); truncated = true; continue; }
|
|
133
|
+
if (ex.data !== undefined) parsed = ex.data;
|
|
134
|
+
else if (text !== null) { try { parsed = JSON.parse(text); } catch { parsed = undefined; } }
|
|
135
|
+
let schema = ex.schema || detectSchema(parsed, text);
|
|
136
|
+
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; }
|
|
137
|
+
const prov = assessProvenance(ex, schema, expected, now, label, add);
|
|
138
|
+
provenance.push({ export: label, schema, ...prov.summary });
|
|
139
|
+
if (schema === 'nix-store-query-text') {
|
|
140
|
+
const lines = String(text !== null ? text : '').split(/\r?\n/).filter(Boolean);
|
|
141
|
+
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; }
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (schema === 'nix-path-info-json') {
|
|
145
|
+
const entries = Array.isArray(parsed) ? parsed.map((e) => [e && e.path, e]) : Object.entries(parsed || {});
|
|
146
|
+
for (const [p, e] of entries) {
|
|
147
|
+
const sp = parseStorePath(normalizePath(p));
|
|
148
|
+
if (!sp) { diag.push({ export: label, kind: 'invalid-store-path', detail: `not a store path: ${String(p).slice(0, 80)}` }); malformed++; continue; }
|
|
149
|
+
if (e === null) { pathInfo.set(sp.path, { references: null, valid: false }); continue; } // invalid path in the store
|
|
150
|
+
if (!e || typeof e !== 'object') { diag.push({ export: label, kind: 'malformed-entry', detail: sp.path }); malformed++; continue; }
|
|
151
|
+
if (pathInfo.size >= b.maxNodes) { truncated = true; break; }
|
|
152
|
+
const refs = Array.isArray(e.references) ? e.references.map((r) => parseStorePath(normalizePath(r))).filter(Boolean).map((r) => r.path) : null;
|
|
153
|
+
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++; }
|
|
154
|
+
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 });
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// derivation show
|
|
159
|
+
const wrapped = parsed && parsed.derivations && typeof parsed.derivations === 'object';
|
|
160
|
+
const table = wrapped ? parsed.derivations : parsed;
|
|
161
|
+
for (const [dp0, e] of Object.entries(table || {})) {
|
|
162
|
+
const dp = normalizePath(dp0);
|
|
163
|
+
const sp = parseStorePath(dp);
|
|
164
|
+
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; }
|
|
165
|
+
if (!e || typeof e !== 'object') { diag.push({ export: label, kind: 'malformed-entry', detail: sp.path }); malformed++; continue; }
|
|
166
|
+
if (drvs.size >= b.maxNodes) { truncated = true; break; }
|
|
167
|
+
const outputs = {};
|
|
168
|
+
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; }
|
|
169
|
+
const inputDrvs = {};
|
|
170
|
+
const rawIn = wrapped && e.inputs ? (e.inputs.drvs || {}) : (e.inputDrvs || {});
|
|
171
|
+
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 : []); }
|
|
172
|
+
const rawSrcs = wrapped && e.inputs ? (e.inputs.srcs || []) : (e.inputSrcs || []);
|
|
173
|
+
const inputSrcs = (Array.isArray(rawSrcs) ? rawSrcs : []).map((s) => parseStorePath(normalizePath(s))).filter(Boolean).map((s) => s.path);
|
|
174
|
+
let env = e.env && typeof e.env === 'object' ? e.env : null;
|
|
175
|
+
if (env && JSON.stringify(env).length > b.maxEnvBytes) { env = null; diag.push({ export: label, kind: 'env-too-large', detail: sp.name }); }
|
|
176
|
+
drvs.set(sp.path, { outputs, inputDrvs, inputSrcs, system: typeof e.system === 'string' ? e.system : null, builder: typeof e.builder === 'string' ? e.builder : null, env });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
out.provenance = provenance;
|
|
180
|
+
if (truncated) { add('truncated', `an export exceeded the node budget (${b.maxNodes}) or size limit; the inventory is partial`); }
|
|
181
|
+
if (malformed) add('malformed', `${malformed} entr${malformed === 1 ? 'y' : 'ies'} could not be used (see diagnostics)`);
|
|
182
|
+
|
|
183
|
+
// ── node table ──
|
|
184
|
+
const nodes = new Map();
|
|
185
|
+
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); };
|
|
186
|
+
const outputOwner = new Map(); // output path -> {drv, outputName}
|
|
187
|
+
for (const [dp, d] of drvs) { for (const [n, p] of Object.entries(d.outputs)) if (p) outputOwner.set(p, { drv: dp, output: n }); }
|
|
188
|
+
const edges = [];
|
|
189
|
+
const edgeKeys = new Set();
|
|
190
|
+
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 } : {}) }); };
|
|
191
|
+
|
|
192
|
+
for (const [p, info] of pathInfo) {
|
|
193
|
+
const n = node(p, 'output');
|
|
194
|
+
n.narHash = info.narHash || null; n.deriver = info.deriver || null;
|
|
195
|
+
if (info.references === null && info.valid !== false) n.missing.push('references');
|
|
196
|
+
if (!info.narHash) n.missing.push('narHash');
|
|
197
|
+
if (!info.deriver) n.missing.push('deriver');
|
|
198
|
+
if (info.references) for (const r of info.references) if (r !== p) addEdge(p, r, 'reference', null);
|
|
199
|
+
}
|
|
200
|
+
for (const p of queryPaths) { const n = node(p, 'output'); n.queryOnly = !pathInfo.has(p); }
|
|
201
|
+
for (const [dp, d] of drvs) {
|
|
202
|
+
const n = node(dp, 'derivation');
|
|
203
|
+
n.system = d.system; n.outputs = Object.entries(d.outputs).map(([name, path]) => ({ name, path })); n.sources = d.inputSrcs;
|
|
204
|
+
const nv = inferNameVersion(n.storeName, d.env);
|
|
205
|
+
n.pname = nv.pname; n.version = nv.version; n.versionAuthority = nv.authority; n.identity = d.env ? 'derivation-env' : 'drv-path-only';
|
|
206
|
+
if (!d.env) n.missing.push('env');
|
|
207
|
+
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 }));
|
|
208
|
+
if (d.env && typeof d.env.urls === 'string') n.upstreamUrls = d.env.urls.split(/\s+/).filter(Boolean).slice(0, 8);
|
|
209
|
+
if (d.env && typeof d.env.rev === 'string') n.upstreamRev = d.env.rev;
|
|
210
|
+
// role of each input derivation, from the environment lists that name its output paths
|
|
211
|
+
const rolesFor = (outPaths) => {
|
|
212
|
+
const roles = new Set();
|
|
213
|
+
if (!d.env) return roles;
|
|
214
|
+
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); }
|
|
215
|
+
return roles;
|
|
216
|
+
};
|
|
217
|
+
for (const [ip, outs] of Object.entries(d.inputDrvs)) {
|
|
218
|
+
const callee = drvs.get(ip);
|
|
219
|
+
const outPaths = callee ? Object.values(callee.outputs).filter(Boolean) : [];
|
|
220
|
+
const roles = rolesFor(outPaths);
|
|
221
|
+
const role = roles.size ? [...roles].sort().join('|') : 'build';
|
|
222
|
+
node(ip, 'derivation');
|
|
223
|
+
addEdge(dp, ip, 'input-drv', role);
|
|
224
|
+
const e = edges.find((x) => x.from === dp && x.to === ip && x.kind === 'input-drv'); if (e) e.outputsUsed = outs;
|
|
225
|
+
}
|
|
226
|
+
for (const s of d.inputSrcs) { const sn = node(s, 'source'); addEdge(dp, s, 'input-src', 'source'); sn.scopes.add('source'); }
|
|
227
|
+
// link derivation <-> outputs
|
|
228
|
+
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); }
|
|
229
|
+
}
|
|
230
|
+
for (const n of nodes.values()) {
|
|
231
|
+
if (n.kind === 'output') {
|
|
232
|
+
const owner = outputOwner.get(n.id) || (n.deriver && drvs.has(n.deriver) ? { drv: n.deriver } : null);
|
|
233
|
+
const d = owner ? drvs.get(owner.drv) : null;
|
|
234
|
+
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; }
|
|
235
|
+
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'; }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── roots and scopes ──
|
|
240
|
+
let roots = (expected.roots || []).map((r) => parseStorePath(r)).filter(Boolean).map((r) => r.path);
|
|
241
|
+
const fromExpected = roots.length > 0;
|
|
242
|
+
if (!roots.length && pathInfo.size) {
|
|
243
|
+
const referenced = new Set();
|
|
244
|
+
for (const [p, info] of pathInfo) for (const r of info.references || []) if (r !== p) referenced.add(r);
|
|
245
|
+
roots = [...pathInfo.keys()].filter((p) => !referenced.has(p));
|
|
246
|
+
}
|
|
247
|
+
if (!roots.length && queryPaths.size === 1) roots = [...queryPaths];
|
|
248
|
+
const rootDrvs = [];
|
|
249
|
+
for (const r of roots) { const n = nodes.get(r); if (n && n.deriver && drvs.has(n.deriver)) rootDrvs.push(n.deriver); }
|
|
250
|
+
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`);
|
|
251
|
+
if (!roots.length) add('no-root', 'no root path could be determined: scopes are not assigned');
|
|
252
|
+
out.roots = roots;
|
|
253
|
+
// runtime closure
|
|
254
|
+
const runtime = new Set(); const rq = [...roots.filter((r) => nodes.has(r))];
|
|
255
|
+
for (const r of rq) runtime.add(r);
|
|
256
|
+
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); } }
|
|
257
|
+
for (const id of runtime) nodes.get(id).scopes.add('runtime');
|
|
258
|
+
// build graph (from the root derivations)
|
|
259
|
+
const buildSeen = new Map(); const bq = [];
|
|
260
|
+
for (const rd of rootDrvs) { buildSeen.set(rd, new Set(['target'])); bq.push(rd); }
|
|
261
|
+
while (bq.length) {
|
|
262
|
+
const u = bq.shift();
|
|
263
|
+
for (const e of edges) if (e.from === u && e.kind === 'input-drv') {
|
|
264
|
+
const roles = new Set(String(e.role || 'build').split('|'));
|
|
265
|
+
const cur = buildSeen.get(e.to) || new Set();
|
|
266
|
+
const before = cur.size;
|
|
267
|
+
for (const r of roles) cur.add(r);
|
|
268
|
+
// roles propagate: a build-only tool's own inputs are build inputs of the target
|
|
269
|
+
if (!buildSeen.has(e.to) || cur.size !== before) { buildSeen.set(e.to, cur); bq.push(e.to); }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const [id, roles] of buildSeen) {
|
|
273
|
+
const n = nodes.get(id); if (!n) continue;
|
|
274
|
+
n.scopes.add('build');
|
|
275
|
+
if ([...roles].every((r) => r === 'test' || r === 'target')) { if (roles.has('test')) n.scopes.add('test'); }
|
|
276
|
+
for (const r of roles) if (r !== 'target') n.roles.add(r);
|
|
277
|
+
}
|
|
278
|
+
// output nodes inherit the build/test scope of their derivation when they were not found in the runtime closure
|
|
279
|
+
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'); } }
|
|
280
|
+
|
|
281
|
+
// same-name different-build groups
|
|
282
|
+
// distinct BUILDS: outputs of one derivation (out, dev, ...) are one build; different derivations are different builds
|
|
283
|
+
const groups = new Map();
|
|
284
|
+
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); }
|
|
285
|
+
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; }
|
|
286
|
+
|
|
287
|
+
// dangling references / inputDrvs make a graph open
|
|
288
|
+
let openRuntime = false; let openBuild = false;
|
|
289
|
+
for (const [p, info] of pathInfo) for (const r of info.references || []) if (!pathInfo.has(r) && r !== p) { openRuntime = true; }
|
|
290
|
+
for (const d of drvs.values()) for (const ip of Object.keys(d.inputDrvs)) if (!drvs.has(ip)) openBuild = true;
|
|
291
|
+
if (pathInfo.size && openRuntime) add('open-closure', 'some references point at paths that are not in the export: the runtime closure is not closed');
|
|
292
|
+
if (drvs.size && openBuild) add('open-build-graph', 'some inputDrvs are not described in the export: the build graph is not closed');
|
|
293
|
+
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++; }
|
|
294
|
+
const missingMeta = [...nodes.values()].filter((n) => n.missing.length && n.kind !== 'source');
|
|
295
|
+
if (missingMeta.length) add('missing-metadata', `${missingMeta.length} node(s) lack metadata (${[...new Set(missingMeta.flatMap((n) => n.missing))].join(', ')})`, { count: missingMeta.length });
|
|
296
|
+
|
|
297
|
+
for (const n of nodes.values()) {
|
|
298
|
+
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 });
|
|
299
|
+
}
|
|
300
|
+
out.nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
301
|
+
out.edges = edges.sort((a, b) => `${a.from}${a.to}${a.kind}`.localeCompare(`${b.from}${b.to}${b.kind}`));
|
|
302
|
+
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 };
|
|
303
|
+
if (truncated) out.status = 'partial';
|
|
304
|
+
else if (malformed) out.status = 'partial';
|
|
305
|
+
|
|
306
|
+
// ── claims ──
|
|
307
|
+
const blocking = (kinds) => dis.some((d) => kinds.includes(d.kind));
|
|
308
|
+
const common = ['foreign-target', 'foreign-revision', 'stale-export', 'unverified-provenance', 'truncated', 'malformed', 'ambiguous-root', 'no-root', 'invalid-signature', 'unsigned'];
|
|
309
|
+
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'));
|
|
310
|
+
const exactBuild = drvs.size > 0 && !openBuild && !blocking(common) && [...nodes.values()].filter((n) => n.kind === 'derivation').every((n) => !n.missing.includes('env'));
|
|
311
|
+
out.claims = claims(exactBuild, exactRuntime, dis);
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function claims(exactBuildGraph, exactRuntimeClosure, dis) {
|
|
316
|
+
return {
|
|
317
|
+
exactBuildGraph, exactRuntimeClosure, exactInventory: exactBuildGraph && exactRuntimeClosure,
|
|
318
|
+
completeness: exactBuildGraph || exactRuntimeClosure ? 'complete-for-supplied-target' : 'not-claimed',
|
|
319
|
+
reasons: dis.filter((d) => !['no-export'].includes(d.kind)).map((d) => d.kind),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function assessProvenance(ex, schema, expected, now, label, add) {
|
|
324
|
+
const p = ex.provenance && typeof ex.provenance === 'object' ? ex.provenance : null;
|
|
325
|
+
const summary = { level: 'none', tool: null, command: null, target: null };
|
|
326
|
+
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 }; }
|
|
327
|
+
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;
|
|
328
|
+
let level = 'declared';
|
|
329
|
+
if (!SUPPORTED_TOOLS.has(p.tool)) { add('unverified-provenance', `${label}: tool ${p.tool || '(none)'} is not a supported source for ${schema}`); level = 'none'; }
|
|
330
|
+
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'; }
|
|
331
|
+
else if (!p.target || typeof p.target !== 'object' || !p.target.system) { add('unverified-provenance', `${label}: provenance names no target system`); level = 'none'; }
|
|
332
|
+
if (level !== 'none') {
|
|
333
|
+
const t = p.target || {};
|
|
334
|
+
if (expected.system && t.system !== expected.system) add('foreign-target', `${label}: export is for system ${t.system}, expected ${expected.system}`);
|
|
335
|
+
if (expected.installable && t.installable !== expected.installable) add('foreign-target', `${label}: export is for ${t.installable || '(unnamed)'}, expected ${expected.installable}`);
|
|
336
|
+
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}`); }
|
|
337
|
+
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)})`);
|
|
338
|
+
const at = Date.parse(p.generatedAt);
|
|
339
|
+
if (!Number.isFinite(at)) add('stale-export', `${label}: no usable generation time`);
|
|
340
|
+
else {
|
|
341
|
+
const maxMs = (expected.maxAgeDays ?? 30) * 86400000;
|
|
342
|
+
if (now - at > maxMs) add('stale-export', `${label}: generated ${Math.round((now - at) / 86400000)} days ago (limit ${expected.maxAgeDays ?? 30})`);
|
|
343
|
+
if (Number.isFinite(expected.inputsChangedAtMs) && expected.inputsChangedAtMs > at) add('stale-export', `${label}: the flake inputs changed after this export was generated`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// optional attestation
|
|
347
|
+
if (ex.signature) {
|
|
348
|
+
try {
|
|
349
|
+
const body = JSON.stringify({ schema: ex.schema ?? schema, provenance: p, data: ex.data !== undefined ? ex.data : null, text: ex.text !== undefined ? ex.text : null });
|
|
350
|
+
const v = (0,external_node_crypto_.createVerify)('sha256'); v.update(body);
|
|
351
|
+
if (expected.publicKeyPem && v.verify(expected.publicKeyPem, Buffer.from(ex.signature, 'base64'))) { if (level !== 'none') level = 'signed'; }
|
|
352
|
+
else add('invalid-signature', `${label}: the signature does not verify`);
|
|
353
|
+
} catch (e) { add('invalid-signature', `${label}: signature check failed (${e.message})`); }
|
|
354
|
+
} else if (expected.requireSigned) add('unsigned', `${label}: a signed export is required`);
|
|
355
|
+
summary.level = level;
|
|
356
|
+
return { summary };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Canonical body that a producer signs (exported so tests and producers agree on it). */
|
|
360
|
+
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 }); }
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
// EXTERNAL MODULE: ./src/language/haskell-sca.js
|
|
364
|
+
var haskell_sca = __webpack_require__(2437);
|
|
365
|
+
// EXTERNAL MODULE: ./src/language/nix-parser.js
|
|
366
|
+
var nix_parser = __webpack_require__(54352);
|
|
367
|
+
;// CONCATENATED MODULE: ./src/language/nix-sca.js
|
|
368
|
+
// Patch-aware Nix vulnerability matching and reachability (NIX-009).
|
|
369
|
+
//
|
|
370
|
+
// Input is a resolved closure (nix-closure.js), pinned advisory records, optional nixpkgs metadata
|
|
371
|
+
// (`knownVulnerabilities`, license, identifiers) and optional overlay/patch evidence from the Nix source.
|
|
372
|
+
// The outcome for each component is one of:
|
|
373
|
+
//
|
|
374
|
+
// affected the upstream version is inside an affected range and nothing excuses it
|
|
375
|
+
// fixed the version/revision is at or past the fix (or the overlay moves it there)
|
|
376
|
+
// backported-verified in range, BUT a patch whose content hash is in the advisory's fix-patch list is applied
|
|
377
|
+
// possibly-affected in range with an UNVERIFIED patch claim (a CVE-named patch file proves nothing),
|
|
378
|
+
// or only a range overlap, or an ambiguous identity with a plausible match
|
|
379
|
+
// candidate the upstream identity is ambiguous or name-only: matches are leads, never verdicts
|
|
380
|
+
// unknown no usable version/identity/feed: this is NEVER reported as "not affected"
|
|
381
|
+
// not-affected mapped, version outside every affected range
|
|
382
|
+
//
|
|
383
|
+
// A nixpkgs commit, a store hash or a derivation hash is never an upstream software version and is never
|
|
384
|
+
// put in a query. Wrapped Haskell packages reuse the Hackage matcher (PVP ordering). Inclusion in the
|
|
385
|
+
// runtime closure and Haskell import/API reachability are separate evidence tiers: a store path or a build
|
|
386
|
+
// input can establish the first and never the second.
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
const NIX_SCA_VERSION = 'nix-sca/1';
|
|
393
|
+
const HEX40 = /^[0-9a-f]{40}$/i;
|
|
394
|
+
const STORE_HASH = /^[0-9a-df-np-sv-z]{32}$/;
|
|
395
|
+
const UPSTREAM_NOISE = /^(?:git|unstable|master|main|HEAD|latest|unstable-\d{4}-\d{2}-\d{2})$/i;
|
|
396
|
+
|
|
397
|
+
/** A nixpkgs revision, a store-path hash or a date stamp is not an upstream version. */
|
|
398
|
+
function isNotAnUpstreamVersion(v) {
|
|
399
|
+
if (typeof v !== 'string' || !v) return 'no version';
|
|
400
|
+
if (HEX40.test(v)) return 'a 40-hex string is a git revision (for example a nixpkgs commit), not a software version';
|
|
401
|
+
if (STORE_HASH.test(v)) return 'a 32-character base32 string is a store-path hash, not a software version';
|
|
402
|
+
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';
|
|
403
|
+
if (UPSTREAM_NOISE.test(v)) return `"${v}" is a moving ref, not a software version`;
|
|
404
|
+
if (!/\d/.test(v)) return `"${v}" has no numeric component`;
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── generic version ordering for upstream software ───────────────────────────
|
|
409
|
+
const PRE = /^(?:alpha|beta|rc|pre|dev|snapshot|a|b)$/i;
|
|
410
|
+
function tokens(v) { return String(v).toLowerCase().replace(/^v(?=\d)/, '').split(/[^a-z0-9]+/).filter(Boolean).flatMap((t) => t.match(/\d+|[a-z]+/g) || []); }
|
|
411
|
+
function compareUpstream(a, b) {
|
|
412
|
+
const x = tokens(a), y = tokens(b);
|
|
413
|
+
const n = Math.max(x.length, y.length);
|
|
414
|
+
for (let i = 0; i < n; i++) {
|
|
415
|
+
const p = x[i], q = y[i];
|
|
416
|
+
if (p === undefined) return PRE.test(q) ? 1 : (/^\d+$/.test(q) && Number(q) === 0 ? 0 : -1);
|
|
417
|
+
if (q === undefined) return PRE.test(p) ? -1 : (/^\d+$/.test(p) && Number(p) === 0 ? 0 : 1);
|
|
418
|
+
const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
|
|
419
|
+
if (pn && qn) { const d = Number(p) - Number(q); if (d) return d < 0 ? -1 : 1; continue; }
|
|
420
|
+
if (pn !== qn) { if (!pn && PRE.test(p)) return -1; if (!qn && PRE.test(q)) return 1; return pn ? 1 : -1; }
|
|
421
|
+
if (p !== q) return p < q ? -1 : 1;
|
|
422
|
+
}
|
|
423
|
+
return 0;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── advisories ───────────────────────────────────────────────────────────────
|
|
427
|
+
const sha = (s) => String(s || '').toLowerCase().replace(/^sha256[-:]/, '');
|
|
428
|
+
function eventsToRanges(events) {
|
|
429
|
+
const out = []; let lo = null;
|
|
430
|
+
for (const e of events || []) {
|
|
431
|
+
if ('introduced' in e) lo = e.introduced === '0' ? null : e.introduced;
|
|
432
|
+
else if ('fixed' in e) { out.push({ lo, hi: e.fixed, hiInc: false }); lo = null; }
|
|
433
|
+
else if ('last_affected' in e) { out.push({ lo, hi: e.last_affected, hiInc: true }); lo = null; }
|
|
434
|
+
}
|
|
435
|
+
if (events && events.some((e) => 'introduced' in e) && (lo !== null || !out.length || !events.some((e) => 'fixed' in e || 'last_affected' in e))) out.push({ lo, hi: null, hiInc: false });
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
const inRange = (v, r) => (r.lo === null || compareUpstream(v, r.lo) >= 0) && (r.hi === null || (r.hiInc ? compareUpstream(v, r.hi) <= 0 : compareUpstream(v, r.hi) < 0));
|
|
439
|
+
|
|
440
|
+
/** Normalize a non-Hackage OSV-style record into what Nix matching needs. */
|
|
441
|
+
function normalizeGenericAdvisory(rec) {
|
|
442
|
+
if (!rec || typeof rec.id !== 'string') return null;
|
|
443
|
+
const aliases = [...new Set((rec.aliases || []).filter((x) => typeof x === 'string'))];
|
|
444
|
+
const affected = [];
|
|
445
|
+
for (const a of rec.affected || []) {
|
|
446
|
+
if (!a || !a.package) continue;
|
|
447
|
+
const ranges = (a.ranges || []).filter((r) => r.type !== 'GIT').flatMap((r) => eventsToRanges(r.events));
|
|
448
|
+
const git = (a.ranges || []).filter((r) => r.type === 'GIT').map((r) => ({ repo: r.repo || null, events: r.events || [] }));
|
|
449
|
+
affected.push({
|
|
450
|
+
ecosystem: a.package.ecosystem || null, name: a.package.name || null, purl: a.package.purl || null, cpe: a.package.cpe || null,
|
|
451
|
+
ranges, versions: Array.isArray(a.versions) ? a.versions : [], git,
|
|
452
|
+
fixedIn: [...new Set((a.ranges || []).flatMap((r) => (r.events || []).filter((e) => 'fixed' in e).map((e) => e.fixed)))],
|
|
453
|
+
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 })),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return { id: rec.id, ids: [rec.id, ...aliases], cves: [rec.id, ...aliases].filter((x) => /^CVE-/.test(x)), summary: rec.summary || '', withdrawn: rec.withdrawn || null, published: rec.published || null, modified: rec.modified || null, references: (rec.references || []).map((r) => r.url).filter(Boolean), affected };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
class NixAdvisoryData {
|
|
460
|
+
constructor({ records = [], hackage = null, source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30 } = {}) {
|
|
461
|
+
this.source = source; this.generatedAt = generatedAt; this.maxAgeDays = maxAgeDays;
|
|
462
|
+
this.records = records.map(normalizeGenericAdvisory).filter(Boolean);
|
|
463
|
+
this.hackage = hackage; // an AdvisoryDb (HS-009) for wrapped Haskell packages
|
|
464
|
+
const gen = generatedAt ? Date.parse(generatedAt) : NaN;
|
|
465
|
+
this.ageDays = Number.isFinite(gen) ? (now - gen) / 86400000 : null;
|
|
466
|
+
this.stale = this.ageDays === null ? true : this.ageDays > maxAgeDays;
|
|
467
|
+
this.feed = { source, generatedAt, status: this.stale ? 'stale-cache' : 'current', records: this.records.length };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── upstream identity ────────────────────────────────────────────────────────
|
|
472
|
+
const HASKELL_ENV_HINTS = ['libraryHaskellDepends', 'setupHaskellDepends', 'executableHaskellDepends', 'isLibrary', 'isExecutable', 'enableSeparateDataOutput', 'compilerName', 'enableLibraryProfiling'];
|
|
473
|
+
function purlFromUrl(u) {
|
|
474
|
+
const m = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:\/|$)/.exec(u || '');
|
|
475
|
+
if (m) return { purl: `pkg:github/${m[1].toLowerCase()}/${m[2].toLowerCase()}`, host: 'github' };
|
|
476
|
+
const g = /^https?:\/\/gitlab\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:\/|$)/.exec(u || '');
|
|
477
|
+
if (g) return { purl: `pkg:gitlab/${g[1].toLowerCase()}/${g[2].toLowerCase()}`, host: 'gitlab' };
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The identity a component can be matched under. Never invents authority: `explicit` needs the package
|
|
483
|
+
* metadata to say so, `src-derived` needs a recognised source host, anything else is `name-only`.
|
|
484
|
+
*/
|
|
485
|
+
function upstreamIdentity(node, drvEnv, meta, overlay) {
|
|
486
|
+
const cands = [];
|
|
487
|
+
const version = (overlay && overlay.version) || node.version;
|
|
488
|
+
const vguard = isNotAnUpstreamVersion(version);
|
|
489
|
+
const base = { pname: node.pname, version: vguard ? null : version, versionRejected: vguard || null, versionSource: overlay && overlay.version ? 'overlay' : node.versionAuthority };
|
|
490
|
+
const isHs = drvEnv && HASKELL_ENV_HINTS.some((k) => k in drvEnv);
|
|
491
|
+
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' });
|
|
492
|
+
const ids = meta && meta.identifiers ? meta.identifiers : null;
|
|
493
|
+
if (ids && typeof ids.purl === 'string') cands.push({ purl: ids.purl, authority: 'explicit', basis: 'meta.identifiers.purl' });
|
|
494
|
+
const cpes = ids ? [].concat(ids.cpe || [], ids.possibleCPEs || [], ids.v1 && ids.v1.cpeParts ? [ids.v1.cpeParts] : []).filter(Boolean) : [];
|
|
495
|
+
const cpeStrings = cpes.map((c) => (typeof c === 'string' ? c : (c.vendor && c.product ? `${c.vendor}:${c.product}` : null))).filter(Boolean);
|
|
496
|
+
const uniqueCpe = [...new Set(cpeStrings)];
|
|
497
|
+
if (uniqueCpe.length === 1 && ids && ids.cpe && !ids.possibleCPEs) cands.push({ cpe: uniqueCpe[0], authority: 'explicit', basis: 'meta.identifiers.cpe' });
|
|
498
|
+
else for (const c of uniqueCpe) cands.push({ cpe: c, authority: 'candidate', basis: uniqueCpe.length > 1 ? 'one of several possible CPEs' : 'possible CPE' });
|
|
499
|
+
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}` }); }
|
|
500
|
+
if (meta && meta.homepage) { const p = purlFromUrl(meta.homepage); if (p) cands.push({ purl: p.purl, authority: 'src-derived', basis: 'meta.homepage' }); }
|
|
501
|
+
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' });
|
|
502
|
+
const uniq = []; const seen = new Set();
|
|
503
|
+
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); } }
|
|
504
|
+
const strong = uniq.filter((c) => c.authority === 'explicit' || c.authority === 'src-derived');
|
|
505
|
+
const distinctTargets = new Set(strong.map((c) => c.purl || c.cpe || `${c.ecosystem}:${c.name}`));
|
|
506
|
+
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 };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── overlays and patches ─────────────────────────────────────────────────────
|
|
510
|
+
const segName = (s) => (s && s.kind === 'static' ? s.name : null);
|
|
511
|
+
const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
|
|
512
|
+
|
|
513
|
+
/** pname -> {version?, patches:[{name,source,sha256?,url?}], file, line} from overlay `overrideAttrs` calls. */
|
|
514
|
+
function overlayEvidence(files) {
|
|
515
|
+
const out = {};
|
|
516
|
+
for (const [file, text] of Object.entries(files || {})) {
|
|
517
|
+
if (!/\.nix$/i.test(file) || typeof text !== 'string') continue;
|
|
518
|
+
const parse = (0,nix_parser/* parseNix */.Z$)(text, { file });
|
|
519
|
+
if (!parse.ast) continue;
|
|
520
|
+
const stack = [parse.ast];
|
|
521
|
+
while (stack.length) {
|
|
522
|
+
const n = stack.pop();
|
|
523
|
+
if (!n || typeof n !== 'object') continue;
|
|
524
|
+
if (n.type === 'attrset') {
|
|
525
|
+
for (const b of n.bindings || []) {
|
|
526
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
527
|
+
const key = (b.path || []).map(segName);
|
|
528
|
+
if (key.length !== 1 || !key[0]) continue;
|
|
529
|
+
const call = unparen(b.value);
|
|
530
|
+
if (!call || call.type !== 'app') continue;
|
|
531
|
+
const fn = unparen(call.fn);
|
|
532
|
+
const last = fn && fn.type === 'select' ? segName(fn.attrpath[fn.attrpath.length - 1]) : null;
|
|
533
|
+
if (last !== 'overrideAttrs') continue;
|
|
534
|
+
const rec = out[key[0]] || (out[key[0]] = { patches: [], file, line: b.span ? b.span.startLine : null, mechanism: 'overlay-overrideAttrs' });
|
|
535
|
+
let body = unparen(call.arg);
|
|
536
|
+
while (body && body.type === 'lambda') body = unparen(body.body);
|
|
537
|
+
if (body && body.type === 'attrset') {
|
|
538
|
+
for (const ob of body.bindings) {
|
|
539
|
+
if (ob.kind !== 'attr') continue;
|
|
540
|
+
const k = (ob.path || []).map(segName).join('.');
|
|
541
|
+
if (k === 'version') { const v = unparen(ob.value); if (v && v.literal !== undefined) rec.version = v.literal; }
|
|
542
|
+
if (k === 'patches') collectPatches(ob.value, rec.patches);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
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); } }
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
function collectPatches(node, acc) {
|
|
553
|
+
const n = unparen(node);
|
|
554
|
+
if (!n) return;
|
|
555
|
+
if (n.type === 'list') for (const it of n.items) collectPatches(it, acc);
|
|
556
|
+
else if (n.type === 'binop') { collectPatches(n.left, acc); collectPatches(n.right, acc); }
|
|
557
|
+
else if (n.type === 'path' && n.literal) acc.push({ name: String(n.literal).replace(/^.*\//, ''), source: 'path', sha256: null });
|
|
558
|
+
else if (n.type === 'app') {
|
|
559
|
+
const flat = []; let f = n; while (f && f.type === 'app') { flat.unshift(f.arg); f = unparen(f.fn); }
|
|
560
|
+
const nm = f && f.type === 'select' ? segName(f.attrpath[f.attrpath.length - 1]) : (f && f.type === 'ident' ? f.name : null);
|
|
561
|
+
if (nm === 'fetchpatch' || nm === 'fetchpatch2') {
|
|
562
|
+
const a = unparen(flat[0]); let url = null; let hash = null;
|
|
563
|
+
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; }
|
|
564
|
+
acc.push({ name: url ? url.replace(/[?#].*$/, '').replace(/^.*\//, '') : 'fetchpatch', source: 'fetchpatch', url, sha256: hash ? sha(hash) : null });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── matching ─────────────────────────────────────────────────────────────────
|
|
570
|
+
const CVE_IN_NAME = /CVE-\d{4}-\d{4,}/i;
|
|
571
|
+
function patchEvidence(patches, advisory, aff) {
|
|
572
|
+
const claims = []; let verified = null;
|
|
573
|
+
const fixHashes = new Set((aff.fixPatches || []).map((p) => p.sha256).filter(Boolean));
|
|
574
|
+
const fixNames = new Set((aff.fixPatches || []).map((p) => p.name).filter(Boolean));
|
|
575
|
+
for (const p of patches || []) {
|
|
576
|
+
const cveInName = (CVE_IN_NAME.exec(p.name || '') || [])[0];
|
|
577
|
+
const namesThis = cveInName && advisory.ids.some((i) => i.toUpperCase() === cveInName.toUpperCase());
|
|
578
|
+
if (p.sha256 && fixHashes.has(sha(p.sha256))) { verified = verified || { patch: p.name, by: 'content hash matches an advisory fix patch' }; continue; }
|
|
579
|
+
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' });
|
|
580
|
+
}
|
|
581
|
+
return { verified, claims };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function genericMatch(adv, aff, ident, node, patches) {
|
|
585
|
+
const v = ident.version;
|
|
586
|
+
const out = { status: 'unknown', reason: '' };
|
|
587
|
+
if (aff.git.length && node.upstream && node.upstream.rev) {
|
|
588
|
+
const rev = node.upstream.rev.toLowerCase();
|
|
589
|
+
const fixed = aff.git.flatMap((g) => g.events.filter((e) => 'fixed' in e).map((e) => String(e.fixed).toLowerCase()));
|
|
590
|
+
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()))]);
|
|
591
|
+
if (fixed.includes(rev)) return { status: 'fixed', reason: 'the source revision is the advisory fix commit' };
|
|
592
|
+
if ([...listed].includes(rev)) return { status: 'affected', reason: 'the source revision is an affected commit listed by the advisory' };
|
|
593
|
+
out.reason = 'the source revision is neither the fix nor a listed affected commit; commit ordering is not available, so no verdict';
|
|
594
|
+
if (!v) return out;
|
|
595
|
+
}
|
|
596
|
+
if (!v) return { status: 'unknown', reason: ident.versionRejected || 'no upstream version' };
|
|
597
|
+
let inside = aff.ranges.some((r) => inRange(v, r));
|
|
598
|
+
if (!inside && aff.versions.some((x) => compareUpstream(v, x) === 0)) inside = true;
|
|
599
|
+
if (!aff.ranges.length && !aff.versions.length) return { status: 'unknown', reason: 'the advisory has no version information for this package' };
|
|
600
|
+
if (inside) return { status: 'affected', reason: 'the version is inside an affected range' };
|
|
601
|
+
if ((aff.fixedIn || []).some((f) => compareUpstream(v, f) >= 0)) return { status: 'fixed', reason: `the version is at or past the fix (${aff.fixedIn.join(', ')})` };
|
|
602
|
+
return { status: 'not-affected', reason: 'the version is outside every affected range' };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* @param {{closure: object, data: NixAdvisoryData|null, meta?: Record<string,object>, overlays?: object,
|
|
607
|
+
* haskellUsage?: {imports: object[], callees: Set<string>}, services?: Array<{service:string, packages:string[]}>,
|
|
608
|
+
* symbols?: Record<string,string[]>, kev?: Set<string>|null, epss?: Record<string,number>|null,
|
|
609
|
+
* licensePolicy?: object, drvEnv?: Record<string,object>}} opts
|
|
610
|
+
* meta is keyed by derivation name or pname; drvEnv by derivation path (the env block of the drv show export).
|
|
611
|
+
*/
|
|
612
|
+
function matchNixVulnerabilities(opts = {}) {
|
|
613
|
+
const { closure, data = null, meta = {}, overlays = {}, haskellUsage = null, services = [], symbols = {}, kev = null, epss = null } = opts;
|
|
614
|
+
const findings = []; const statuses = [];
|
|
615
|
+
const feed = data ? data.feed : { source: null, generatedAt: null, status: 'feed-unavailable', records: 0 };
|
|
616
|
+
const nodes = (closure && closure.nodes) || [];
|
|
617
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
618
|
+
// one subject per derivation: its outputs are one build
|
|
619
|
+
const subjects = new Map();
|
|
620
|
+
const roots = new Set((closure && closure.roots) || []);
|
|
621
|
+
for (const n of nodes) {
|
|
622
|
+
if (n.kind !== 'output' || roots.has(n.id)) continue; // the target itself is not a dependency of itself
|
|
623
|
+
const key = n.deriver || n.id;
|
|
624
|
+
if (!subjects.has(key)) subjects.set(key, { drv: n.deriver || null, outputs: [], node: n });
|
|
625
|
+
const s = subjects.get(key); s.outputs.push(n);
|
|
626
|
+
if (n.outputName === 'out' || (s.node.outputName !== 'out' && n.scopes.includes('runtime'))) s.node = n;
|
|
627
|
+
}
|
|
628
|
+
const summary = { components: 0, matched: 0, mappedNoMatch: 0, unmapped: 0, candidates: 0 };
|
|
629
|
+
for (const s of subjects.values()) {
|
|
630
|
+
const node = s.node;
|
|
631
|
+
const env = (opts.drvEnv && s.drv && opts.drvEnv[s.drv]) || null;
|
|
632
|
+
const m = meta[node.pname] || meta[node.storeName] || null;
|
|
633
|
+
const ov = overlays[node.pname] || null;
|
|
634
|
+
const patches = [...(node.patches || []), ...((ov && ov.patches) || [])];
|
|
635
|
+
const ident = upstreamIdentity(node, env, m, ov);
|
|
636
|
+
summary.components++;
|
|
637
|
+
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 };
|
|
638
|
+
const inclusion = node.scopes.includes('runtime') ? 'runtime-closure' : (node.scopes.length ? 'build-only' : 'unknown');
|
|
639
|
+
const svc = services.filter((x) => x.packages.some((p) => p === node.pname || p === node.storeName)).map((x) => x.service);
|
|
640
|
+
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' } };
|
|
641
|
+
const base = { name: node.pname, version: ident.version, nixBuild, identity: { authority: ident.authority, candidates: ident.candidates, versionSource: ident.versionSource, versionRejected: ident.versionRejected }, tiers, feed };
|
|
642
|
+
const push = (st, extra = {}) => statuses.push({ ...base, status: st.status, reason: st.reason, ...extra });
|
|
643
|
+
|
|
644
|
+
// knownVulnerabilities marked by nixpkgs themselves
|
|
645
|
+
const kvs = (m && Array.isArray(m.knownVulnerabilities) ? m.knownVulnerabilities : []).filter((x) => typeof x === 'string');
|
|
646
|
+
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 }));
|
|
647
|
+
if (!data) { push({ status: 'unknown', reason: 'no advisory feed was supplied: absence of a match is not a clean result' }); summary.unmapped++; continue; }
|
|
648
|
+
if (ident.versionRejected && !ident.haskell && !(node.upstream && node.upstream.rev)) { push({ status: 'unknown', reason: `the version cannot be queried: ${ident.versionRejected}` }); summary.unmapped++; continue; }
|
|
649
|
+
|
|
650
|
+
let matched = false; let mapped = false;
|
|
651
|
+
// Hackage (wrapped Haskell packages): reuse the PVP matcher
|
|
652
|
+
if (ident.haskell && data.hackage && ident.version && (0,haskell_manifests/* parseVersion */.ot)(ident.version)) {
|
|
653
|
+
mapped = true;
|
|
654
|
+
for (const { adv, aff } of data.hackage.forPackage(node.pname)) {
|
|
655
|
+
if (adv.withdrawn) continue;
|
|
656
|
+
const r = (0,haskell_sca/* matchComponent */.nZ)(aff, { name: node.pname, version: ident.version });
|
|
657
|
+
if (r.status === 'not-affected') continue;
|
|
658
|
+
const pe = patchEvidence(patches, { ids: adv.ids }, { fixPatches: [] });
|
|
659
|
+
const st = decide(r, pe, ident);
|
|
660
|
+
matched = true;
|
|
661
|
+
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 }; }
|
|
662
|
+
findings.push(finding({ ...base, tiers: { ...tiers } }, { id: adv.canonicalId, ids: adv.ids, summary: adv.summary, cves: adv.cveAliases }, st.status, st.reason, { source: data.hackage.source, ecosystem: 'Hackage', fixedIn: aff.fixedIn, patchEvidence: pe }, { kev, epss }));
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// generic records by PURL / CPE; only authoritative identities are matched, candidates are leads
|
|
666
|
+
for (const adv of data.records) {
|
|
667
|
+
for (const aff of adv.affected) {
|
|
668
|
+
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));
|
|
669
|
+
if (!target) continue;
|
|
670
|
+
if (aff.ecosystem === 'Hackage') continue; // handled above
|
|
671
|
+
mapped = mapped || target.authority === 'explicit' || target.authority === 'src-derived';
|
|
672
|
+
const gm = genericMatch(adv, aff, ident, node, patches);
|
|
673
|
+
if (gm.status === 'not-affected' && target.authority !== 'candidate' && target.authority !== 'name-only') continue;
|
|
674
|
+
const pe = patchEvidence(patches, adv, aff);
|
|
675
|
+
let st = gm.status === 'affected' ? decide(gm, pe, ident) : gm;
|
|
676
|
+
// identity gate: only an authoritative, unambiguous identity may produce a firm verdict
|
|
677
|
+
if (target.authority === 'candidate' || target.authority === 'name-only' || ident.ambiguous) {
|
|
678
|
+
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` };
|
|
679
|
+
summary.candidates++;
|
|
680
|
+
}
|
|
681
|
+
matched = true;
|
|
682
|
+
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 }));
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (matched) summary.matched++;
|
|
686
|
+
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 }); }
|
|
687
|
+
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 }); }
|
|
688
|
+
}
|
|
689
|
+
return { version: NIX_SCA_VERSION, findings, statuses, feed, summary, licenses: licenseReport(nodes.filter((n) => !roots.has(n.id)), meta, opts.licensePolicy) };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function decide(r, pe, ident) {
|
|
693
|
+
if (r.status === 'possibly-affected') return { status: 'possibly-affected', reason: r.reason };
|
|
694
|
+
if (pe.verified) return { status: 'backported-verified', reason: `in the affected range, but ${pe.verified.by} (${pe.verified.patch})` };
|
|
695
|
+
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})` };
|
|
696
|
+
return { status: 'affected', reason: r.reason };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function finding(base, adv, status, reason, extra, { kev, epss }) {
|
|
700
|
+
const cves = adv.cves || (adv.ids || []).filter((x) => /^CVE-/.test(x));
|
|
701
|
+
const kevHit = cves.length ? (kev ? cves.some((c) => kev.has(c)) : 'unknown') : 'not-applicable';
|
|
702
|
+
const ep = cves.map((c) => epss && epss[c]).filter((x) => typeof x === 'number');
|
|
703
|
+
const level = { affected: 'high', 'possibly-affected': 'medium', candidate: 'low', 'backported-verified': 'info', fixed: 'info', unknown: 'low', 'not-affected': 'info' }[status] || 'low';
|
|
704
|
+
return {
|
|
705
|
+
type: 'vulnerable_dep', ecosystem: 'nix', language: 'nix', capability: 'sca', analysisKind: 'application', evidenceKind: 'closure',
|
|
706
|
+
name: base.name, version: base.version, osvId: adv.id, ids: adv.ids || [adv.id], cveAliases: cves, summary: adv.summary || '',
|
|
707
|
+
status, matchStatus: status, matchReason: reason, severity: level, severityBasis: 'the advisory carries no severity rating',
|
|
708
|
+
nixBuild: base.nixBuild, identity: base.identity, tiers: base.tiers, feed: base.feed,
|
|
709
|
+
kev: kevHit, epss: ep.length ? Math.max(...ep) : 'unknown',
|
|
710
|
+
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 },
|
|
711
|
+
file: null, line: null, vuln: `Vulnerable package in the Nix closure (${status})`, cwe: 'CWE-1104', parser: 'NIX-SCA', family: 'sca',
|
|
712
|
+
description: `${base.name}${base.version ? ` ${base.version}` : ''} (${adv.id}): ${reason}.`,
|
|
713
|
+
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.',
|
|
714
|
+
confidence: status === 'affected' ? 0.85 : status === 'possibly-affected' ? 0.5 : 0.3,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function licenseReport(nodes, meta, policy) {
|
|
719
|
+
const comps = nodes.filter((n) => n.kind === 'output' && (n.outputName === 'out' || !n.outputName)).map((n) => ({ name: n.pname, version: n.version }));
|
|
720
|
+
const md = {};
|
|
721
|
+
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) }; }
|
|
722
|
+
return (0,haskell_sca/* licensePolicy */.ez)(comps, md, policy);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
// EXTERNAL MODULE: ./src/language/haskell-supply.js
|
|
728
|
+
var haskell_supply = __webpack_require__(86349);
|
|
729
|
+
// EXTERNAL MODULE: external "node:child_process"
|
|
730
|
+
var external_node_child_process_ = __webpack_require__(31421);
|
|
731
|
+
// EXTERNAL MODULE: external "node:net"
|
|
732
|
+
var external_node_net_ = __webpack_require__(77030);
|
|
733
|
+
// EXTERNAL MODULE: external "node:os"
|
|
734
|
+
var external_node_os_ = __webpack_require__(48161);
|
|
735
|
+
// EXTERNAL MODULE: external "node:path"
|
|
736
|
+
var external_node_path_ = __webpack_require__(76760);
|
|
737
|
+
;// CONCATENATED MODULE: ./src/language/nix-eval-isolation.js
|
|
738
|
+
// Isolated, opt-in Nix evaluation (NIX-011).
|
|
739
|
+
//
|
|
740
|
+
// Ordinary scanning NEVER evaluates customer Nix: nothing imports this module on the default path. An
|
|
741
|
+
// operator who explicitly asks for evaluation gets it only inside an OS sandbox whose isolation this module
|
|
742
|
+
// has just proved with a probe, and only through an evaluator whose required safety flags were
|
|
743
|
+
// feature-detected. The order is fixed and every step before the last is "before project code runs":
|
|
744
|
+
//
|
|
745
|
+
// 1. detectSandbox which backend exists on this host (macOS sandbox-exec, Linux bubblewrap), or none
|
|
746
|
+
// 2. probeSandbox run a probe INSIDE the sandbox that tries to read outside the allowlist, write
|
|
747
|
+
// outside it, reach the network, reach a daemon socket, spawn a process and see a
|
|
748
|
+
// credential variable; the sandbox is accepted only if every attempt is refused
|
|
749
|
+
// 3. detectEvaluator run `--version` / `eval --help` and require every safety flag to exist
|
|
750
|
+
// 4. runIsolatedEval run the target-scoped evaluation with a clean environment, a read-only allowlist,
|
|
751
|
+
// a wall-clock deadline, an output cap and a memory ceiling; kill the whole process group
|
|
752
|
+
//
|
|
753
|
+
// Anything that fails earlier returns `unsupported` or `blocked` with `projectCodeEvaluated:false`; the
|
|
754
|
+
// static findings of the scan are never touched (`staticFindingsRetained:true`) and the result says how to
|
|
755
|
+
// import a supplied export instead. Flags are NOT the boundary: the filesystem/network/process boundary is
|
|
756
|
+
// the sandbox, and the probe tests it independently of any flag. Nix's own build sandbox and restricted
|
|
757
|
+
// evaluation are additional controls, not substitutes. Nothing here runs `nixos-rebuild`, activates a
|
|
758
|
+
// configuration, builds anything or edits a host setting.
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
const NIX_EVAL_VERSION = 'nix-eval-isolation/1';
|
|
768
|
+
const DEFAULT_LIMITS = Object.freeze({ deadlineMs: 20_000, maxOutputBytes: 1 << 20, maxRssMb: 512, killGraceMs: 1500, probeDeadlineMs: 15_000 });
|
|
769
|
+
/** Flags the evaluator must support; a missing one means `unsupported`, never "run without it". */
|
|
770
|
+
const REQUIRED_EVAL_FLAGS = Object.freeze(['--offline', '--no-update-lock-file', '--no-write-lock-file', '--pure-eval', '--json', '--option']);
|
|
771
|
+
/** `--option name value` settings that are always passed. */
|
|
772
|
+
const SAFETY_OPTIONS = Object.freeze({
|
|
773
|
+
'allow-import-from-derivation': 'false',
|
|
774
|
+
'allow-unsafe-native-code-during-evaluation': 'false',
|
|
775
|
+
'accept-flake-config': 'false',
|
|
776
|
+
'restrict-eval': 'true',
|
|
777
|
+
'allowed-uris': '',
|
|
778
|
+
'substituters': '',
|
|
779
|
+
'max-jobs': '0',
|
|
780
|
+
});
|
|
781
|
+
const CLEAN_PATH = '/usr/bin:/bin';
|
|
782
|
+
const ATTR_RE = /^[A-Za-z0-9_][A-Za-z0-9_.+'-]*(?:\.(?:[A-Za-z0-9_][A-Za-z0-9_+'-]*|"[A-Za-z0-9_.+-]+"))*$/;
|
|
783
|
+
|
|
784
|
+
// ── 1. sandbox backend ───────────────────────────────────────────────────────
|
|
785
|
+
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; };
|
|
786
|
+
|
|
787
|
+
function detectSandbox(opts = {}) {
|
|
788
|
+
const plat = opts.platform || (0,external_node_os_.platform)();
|
|
789
|
+
const find = opts.which || which;
|
|
790
|
+
if (opts.forceBackend === 'none') return { available: false, backend: 'none', platform: plat, reason: 'isolation disabled by the caller' };
|
|
791
|
+
if (plat === 'darwin') {
|
|
792
|
+
const p = find('sandbox-exec');
|
|
793
|
+
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' };
|
|
794
|
+
}
|
|
795
|
+
if (plat === 'linux') {
|
|
796
|
+
const p = find('bwrap');
|
|
797
|
+
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' };
|
|
798
|
+
}
|
|
799
|
+
return { available: false, backend: 'none', platform: plat, reason: `no sandbox backend is implemented for ${plat}` };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const sbq = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
803
|
+
const real = (p) => { try { return (0,external_node_fs_.realpathSync)(p); } catch { return (0,external_node_path_.resolve)(p); } };
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* The sandboxed command for a backend. Reads are an ALLOWLIST (the listed roots, system library paths and the
|
|
807
|
+
* evaluator); writes are an allowlist of one scratch directory; network and process creation are denied.
|
|
808
|
+
* @param {{backend:string, path?:string}} sb
|
|
809
|
+
* @param {{execFile:string, args:string[], readOnly:string[], readFiles?:string[], writable:string[], cwd:string}} spec
|
|
810
|
+
*/
|
|
811
|
+
function sandboxCommand(sb, spec) {
|
|
812
|
+
const ro = [...new Set(spec.readOnly.map(real))];
|
|
813
|
+
const files = [...new Set((spec.readFiles || []).map(real))];
|
|
814
|
+
const rw = [...new Set(spec.writable.map(real))];
|
|
815
|
+
const exec = real(spec.execFile);
|
|
816
|
+
if (sb.backend === 'sandbox-exec') {
|
|
817
|
+
const profile = [
|
|
818
|
+
'(version 1)', '(allow default)', '(deny network*)',
|
|
819
|
+
'(deny file-write*)', `(allow file-write* ${rw.map((p) => `(subpath ${sbq(p)})`).join(' ')} (subpath "/dev"))`,
|
|
820
|
+
'(deny file-read-data (subpath "/"))',
|
|
821
|
+
`(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(' ')})`,
|
|
822
|
+
'(deny process-exec*)', `(allow process-exec (literal ${sbq(exec)}) (subpath "/nix/store"))`,
|
|
823
|
+
].join('\n');
|
|
824
|
+
return { file: sb.path, args: ['-p', profile, exec, ...spec.args], profile };
|
|
825
|
+
}
|
|
826
|
+
if (sb.backend === 'bwrap') {
|
|
827
|
+
const a = ['--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp'];
|
|
828
|
+
for (const p of ['/usr', '/lib', '/lib64', '/bin', '/etc/ssl', '/nix/store']) a.push('--ro-bind-try', p, p);
|
|
829
|
+
for (const p of ro) a.push('--ro-bind', p, p);
|
|
830
|
+
for (const p of files) a.push('--ro-bind', p, p);
|
|
831
|
+
for (const p of rw) a.push('--bind', p, p);
|
|
832
|
+
a.push('--chdir', spec.cwd, exec, ...spec.args);
|
|
833
|
+
return { file: sb.path, args: a, profile: null };
|
|
834
|
+
}
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ── process supervisor ───────────────────────────────────────────────────────
|
|
839
|
+
function rssOfGroup(pgid) {
|
|
840
|
+
const r = (0,external_node_child_process_.spawnSync)('/bin/ps', ['-o', 'rss=', '-g', String(pgid)], { encoding: 'utf8', timeout: 2000 });
|
|
841
|
+
if (r.status !== 0) return 0;
|
|
842
|
+
return r.stdout.split('\n').map((x) => parseInt(x, 10)).filter(Number.isFinite).reduce((a, b) => a + b, 0) / 1024;
|
|
843
|
+
}
|
|
844
|
+
function killGroup(pid, sig) { try { process.kill(-pid, sig); } catch { try { process.kill(pid, sig); } catch { /* gone */ } } }
|
|
845
|
+
|
|
846
|
+
/** Run a command to completion under a deadline, an output cap and a memory ceiling. Never throws. */
|
|
847
|
+
function supervise(file, args, { env, cwd, deadlineMs, maxOutputBytes, maxRssMb, killGraceMs = 1500 }) {
|
|
848
|
+
return new Promise((resolveP) => {
|
|
849
|
+
const t0 = Date.now();
|
|
850
|
+
let child;
|
|
851
|
+
try { child = (0,external_node_child_process_.spawn)(file, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
852
|
+
catch (e) { return resolveP({ status: 'failed', reason: `could not start: ${e.message}`, stdout: '', stderr: '', exitCode: null, signal: null, ms: 0 }); }
|
|
853
|
+
let out = ''; let err = ''; let bytes = 0; let state = null; let done = false;
|
|
854
|
+
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 }); };
|
|
855
|
+
const stop = (why) => { if (state) return; state = why; killGroup(child.pid, 'SIGTERM'); setTimeout(() => killGroup(child.pid, 'SIGKILL'), killGraceMs).unref(); };
|
|
856
|
+
child.stdout.on('data', (d) => { bytes += d.length; if (bytes > maxOutputBytes) { stop('output-limit'); return; } out += d.toString('utf8'); });
|
|
857
|
+
child.stderr.on('data', (d) => { if (err.length < 8000) err += d.toString('utf8'); });
|
|
858
|
+
const dl = setTimeout(() => stop('timed-out'), deadlineMs);
|
|
859
|
+
const poll = setInterval(() => { if (!state && maxRssMb && rssOfGroup(child.pid) > maxRssMb) stop('resource-limit'); }, 250);
|
|
860
|
+
child.on('error', (e) => finish({ status: 'failed', reason: e.message, exitCode: null, signal: null }));
|
|
861
|
+
child.on('close', (code, signal) => {
|
|
862
|
+
killGroup(child.pid, 'SIGKILL'); // reap any descendants
|
|
863
|
+
finish({ status: state || (code === 0 ? 'ok' : 'failed'), exitCode: code, signal, reason: state ? `${state}${state === 'timed-out' ? ` after ${deadlineMs} ms` : ''}` : (code === 0 ? null : `exit ${code}`) });
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const cleanEnv = (work, extra = {}) => ({ PATH: CLEAN_PATH, HOME: work, TMPDIR: work, LANG: 'C', LC_ALL: 'C', ...extra });
|
|
869
|
+
|
|
870
|
+
// ── 2. probe ─────────────────────────────────────────────────────────────────
|
|
871
|
+
const PROBE_SOURCE = `
|
|
872
|
+
const fs=require('fs'),net=require('net'),cp=require('child_process');
|
|
873
|
+
const [allowedFile,outsideFile,outsideWrite,okWrite,port,sock]=process.argv.slice(2);
|
|
874
|
+
const r={};const t=(k,f)=>{try{r[k]=f()}catch(e){r[k]='refused:'+(e.code||'error')}};
|
|
875
|
+
t('readAllowed',()=>fs.readFileSync(allowedFile,'utf8').trim());
|
|
876
|
+
t('readOutside',()=>{fs.readFileSync(outsideFile,'utf8');return 'READ'});
|
|
877
|
+
t('readHostFile',()=>{fs.readFileSync('/etc/hosts','utf8');return 'READ'});
|
|
878
|
+
t('writeOutside',()=>{fs.writeFileSync(outsideWrite,'x');return 'WROTE'});
|
|
879
|
+
t('writeScratch',()=>{fs.writeFileSync(okWrite,'x');return 'wrote'});
|
|
880
|
+
t('spawn',()=>{cp.execFileSync('/bin/echo',['x']);return 'SPAWNED'});
|
|
881
|
+
t('credentialEnv',()=>process.env.PROBE_CREDENTIAL||'absent');
|
|
882
|
+
let pending=2;const fin=()=>{if(--pending===0){console.log(JSON.stringify(r));process.exit(0)}};
|
|
883
|
+
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()});
|
|
884
|
+
const un=net.connect(sock);un.on('connect',()=>{r.daemonSocket='CONNECTED';un.destroy();fin()});un.on('error',(e)=>{r.daemonSocket='refused:'+e.code;fin()});
|
|
885
|
+
setTimeout(()=>{r.timeout=true;console.log(JSON.stringify(r));process.exit(0)},8000);
|
|
886
|
+
`;
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Prove the isolation of a backend with real attempts, independent of any evaluator flag. The result is
|
|
890
|
+
* `verified` only if the one permitted read worked, the scratch write worked and every other attempt was refused.
|
|
891
|
+
*/
|
|
892
|
+
async function probeSandbox(sb, opts = {}) {
|
|
893
|
+
if (!sb || !sb.available) return { verified: false, backend: sb ? sb.backend : 'none', reason: (sb && sb.reason) || 'no sandbox', attempts: {} };
|
|
894
|
+
const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
|
|
895
|
+
const base = (0,external_node_fs_.mkdtempSync)((0,external_node_path_.join)((0,external_node_os_.tmpdir)(), 'nix-eval-probe-'));
|
|
896
|
+
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');
|
|
897
|
+
for (const d of [allowed, scratch, outside]) (0,external_node_fs_.mkdirSync)(d);
|
|
898
|
+
(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');
|
|
899
|
+
const script = (0,external_node_path_.join)(base, 'probe.cjs'); (0,external_node_fs_.writeFileSync)(script, PROBE_SOURCE);
|
|
900
|
+
const sockPath = (0,external_node_path_.join)(base, 'daemon.sock');
|
|
901
|
+
let connections = 0;
|
|
902
|
+
const tcp = (0,external_node_net_.createServer)((s) => { connections++; s.destroy(); });
|
|
903
|
+
const uds = (0,external_node_net_.createServer)((s) => { connections++; s.destroy(); });
|
|
904
|
+
await new Promise((r) => tcp.listen(0, '127.0.0.1', r));
|
|
905
|
+
await new Promise((r) => uds.listen(sockPath, r));
|
|
906
|
+
const port = tcp.address().port;
|
|
907
|
+
try {
|
|
908
|
+
const exe = opts.nodePath || process.execPath;
|
|
909
|
+
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 });
|
|
910
|
+
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 });
|
|
911
|
+
let attempts = null;
|
|
912
|
+
try { attempts = JSON.parse(r.stdout.trim()); } catch { /* unparseable: not verified */ }
|
|
913
|
+
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: {} };
|
|
914
|
+
const insufficient = [];
|
|
915
|
+
if (attempts.readAllowed !== 'allowed') insufficient.push('the allowlisted read failed: the sandbox is unusable');
|
|
916
|
+
if (attempts.writeScratch !== 'wrote') insufficient.push('the scratch write failed: the sandbox is unusable');
|
|
917
|
+
if (attempts.readOutside === 'READ') insufficient.push('a file outside the allowlist could be read');
|
|
918
|
+
if (attempts.readHostFile === 'READ') insufficient.push('a host file (/etc/hosts) could be read');
|
|
919
|
+
if (attempts.writeOutside === 'WROTE') insufficient.push('a file outside the scratch directory could be written');
|
|
920
|
+
if (attempts.network === 'CONNECTED') insufficient.push('a network connection succeeded');
|
|
921
|
+
if (attempts.daemonSocket === 'CONNECTED') insufficient.push('a unix daemon socket could be reached');
|
|
922
|
+
if (attempts.spawn === 'SPAWNED') insufficient.push('a native process could be spawned');
|
|
923
|
+
if (attempts.credentialEnv && attempts.credentialEnv !== 'absent') insufficient.push('a credential environment variable was visible');
|
|
924
|
+
if (attempts.timeout) insufficient.push('the probe timed out');
|
|
925
|
+
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 };
|
|
926
|
+
} finally { tcp.close(); uds.close(); (0,external_node_fs_.rmSync)(base, { recursive: true, force: true }); }
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ── 3. evaluator feature detection ───────────────────────────────────────────
|
|
930
|
+
/** @param {{file:string, prefixArgs?:string[]}} nix the evaluator command (a stand-in in tests) */
|
|
931
|
+
async function detectEvaluator(nix, sb, ctx) {
|
|
932
|
+
const run = async (args) => {
|
|
933
|
+
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 });
|
|
934
|
+
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 });
|
|
935
|
+
};
|
|
936
|
+
const v = await run(['--version']);
|
|
937
|
+
const ver = (/(\d+\.\d+(?:\.\d+)?)/.exec(v.stdout) || [])[1] || null;
|
|
938
|
+
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() };
|
|
939
|
+
const h = await run(['eval', '--help']);
|
|
940
|
+
const text = `${h.stdout}\n${h.stderr}`;
|
|
941
|
+
const missing = REQUIRED_EVAL_FLAGS.filter((f) => !text.includes(f));
|
|
942
|
+
return { ok: missing.length === 0, version: ver, missing, reason: missing.length ? `the evaluator lacks required safety flag(s): ${missing.join(', ')}` : null };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ── target selection and argv ────────────────────────────────────────────────
|
|
946
|
+
/** A flake output attribute, validated. Anything outside a plain attribute path is refused. */
|
|
947
|
+
function selectTarget({ root, attribute, system = null } = {}) {
|
|
948
|
+
if (typeof root !== 'string' || !root.startsWith('/')) return { ok: false, reason: 'the project root must be an absolute path' };
|
|
949
|
+
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` };
|
|
950
|
+
if (/\.\./.test(attribute)) return { ok: false, reason: 'attribute paths may not contain ..' };
|
|
951
|
+
return { ok: true, installable: `path:${root}#${attribute}`, root, attribute, system };
|
|
952
|
+
}
|
|
953
|
+
function buildEvalArgs(target) {
|
|
954
|
+
const opts = Object.entries(SAFETY_OPTIONS).flatMap(([k, v]) => ['--option', k, v]);
|
|
955
|
+
return ['eval', '--offline', '--no-update-lock-file', '--no-write-lock-file', '--pure-eval', '--json', ...opts, target.installable];
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ── 4. run ───────────────────────────────────────────────────────────────────
|
|
959
|
+
const IMPORT_FALLBACK = Object.freeze({
|
|
960
|
+
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:',
|
|
961
|
+
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'],
|
|
962
|
+
schemas: ['nix-path-info-json', 'nix-derivation-show-json', 'nix-store-query-text'],
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* @param {{nix:{file:string,prefixArgs?:string[]}, root:string, attribute:string, system?:string, limits?:object,
|
|
967
|
+
* sandbox?:object, extraReadOnly?:string[], probe?:Function, env?:object}} o
|
|
968
|
+
* @returns {Promise<{status:'ok'|'unsupported'|'blocked'|'timed-out'|'output-limit'|'resource-limit'|'failed', projectCodeEvaluated:boolean, ...}>}
|
|
969
|
+
*/
|
|
970
|
+
async function runIsolatedEval(o) {
|
|
971
|
+
const limits = { ...DEFAULT_LIMITS, ...(o.limits || {}) };
|
|
972
|
+
const keep = { staticFindingsRetained: true, fallback: IMPORT_FALLBACK, evaluation: 'opt-in' };
|
|
973
|
+
const stopBefore = (status, reason, extra = {}) => ({ status, reason, projectCodeEvaluated: false, ...keep, ...extra });
|
|
974
|
+
const target = selectTarget({ root: o.root, attribute: o.attribute, system: o.system });
|
|
975
|
+
if (!target.ok) return stopBefore('blocked', target.reason);
|
|
976
|
+
const sb = o.sandbox || detectSandbox();
|
|
977
|
+
if (!sb.available) return stopBefore('unsupported', `no isolation is available (${sb.reason}); the project's Nix code was NOT evaluated`, { sandbox: sb });
|
|
978
|
+
const probe = await (o.probe || probeSandbox)(sb, { limits });
|
|
979
|
+
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 });
|
|
980
|
+
const work = (0,external_node_fs_.mkdtempSync)((0,external_node_path_.join)((0,external_node_os_.tmpdir)(), 'nix-eval-work-'));
|
|
981
|
+
try {
|
|
982
|
+
const root = real(o.root);
|
|
983
|
+
const nixFiles = [nix_file(o.nix), ...(o.nix.prefixArgs || []).filter((a) => a && !a.startsWith('-')).map(real)];
|
|
984
|
+
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 };
|
|
985
|
+
const feat = await detectEvaluator(o.nix, sb, ctx);
|
|
986
|
+
if (!feat.ok) return stopBefore('unsupported', `${feat.reason}; the project's Nix code was NOT evaluated`, { sandbox: sb, probe: { verified: true }, evaluator: feat });
|
|
987
|
+
const args = [...(o.nix.prefixArgs || []), ...buildEvalArgs(target)];
|
|
988
|
+
const cmd = sandboxCommand(sb, { execFile: o.nix.file, args, readOnly: ctx.readOnly, readFiles: nixFiles, writable: [ctx.work], cwd: ctx.work });
|
|
989
|
+
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 });
|
|
990
|
+
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 } };
|
|
991
|
+
if (r.status !== 'ok') return { ...base, status: r.status === 'failed' ? 'failed' : r.status, reason: r.reason || r.stderr.slice(0, 200) };
|
|
992
|
+
let data;
|
|
993
|
+
try { data = JSON.parse(r.stdout); } catch { return { ...base, status: 'failed', reason: 'the evaluator output was not JSON' }; }
|
|
994
|
+
const digest = (0,external_node_crypto_.createHash)('sha256').update(r.stdout).digest('hex');
|
|
995
|
+
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 };
|
|
996
|
+
} finally { (0,external_node_fs_.rmSync)(work, { recursive: true, force: true }); }
|
|
997
|
+
}
|
|
998
|
+
const nix_file = (n) => real(n.file);
|
|
999
|
+
|
|
1000
|
+
/** Evaluation health is its OWN state: it never replaces, empties or downgrades the static scan. */
|
|
1001
|
+
function mergeEvaluationHealth(scanHealth, result) {
|
|
1002
|
+
const h = scanHealth && typeof scanHealth === 'object' ? { ...scanHealth } : {};
|
|
1003
|
+
const requested = !!result;
|
|
1004
|
+
h.evaluation = requested
|
|
1005
|
+
? { 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 }
|
|
1006
|
+
: { requested: false, status: 'not-requested', ran: false, reason: 'evaluation is opt-in; the scan is static', fallback: null };
|
|
1007
|
+
h.staticFindingsRetained = true;
|
|
1008
|
+
return h;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
;// CONCATENATED MODULE: ./src/language/resolved-pass.js
|
|
1014
|
+
// 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,
|
|
1015
|
+
// instead of being reachable only from a unit test.
|
|
1016
|
+
//
|
|
1017
|
+
// Haskell dist-newstyle/cache/plan.json and .stack-work/dependencies.json -> a resolved graph (transitive packages, exact
|
|
1018
|
+
// versions, scopes) joined to the declared manifests; stale data is used for nothing and disclosed.
|
|
1019
|
+
// Nix nix-export.json (and .direnv/nix-export.json) -> an imported closure, matched against an advisory
|
|
1020
|
+
// snapshot with upstream-identity and patch-evidence rules; no feed means "unknown", never "clean".
|
|
1021
|
+
// Eval AGENTIC_SECURITY_NIX_EVAL=1 + AGENTIC_SECURITY_NIX_TARGET=<attr> -> an isolated, opt-in evaluation whose state is its
|
|
1022
|
+
// own scan-health field. A scan that did not select it never runs a Nix evaluator.
|
|
1023
|
+
//
|
|
1024
|
+
// Nothing here fetches, builds, evaluates or runs a project's code unless the evaluation is selected AND the sandbox proves its isolation.
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
const RESOLVED_PASS_VERSION = 'resolved-pass/1';
|
|
1037
|
+
|
|
1038
|
+
const PLAN = /(?:^|\/)dist-newstyle\/cache\/plan\.json$/;
|
|
1039
|
+
const STACK_EXPORT = /(?:^|\/)\.stack-work\/dependencies\.json$/;
|
|
1040
|
+
const NIX_EXPORT = /(?:^|\/)nix-export\.json$/;
|
|
1041
|
+
const ACTIONABLE = new Set(['affected', 'possibly-affected', 'candidate', 'backported-verified']);
|
|
1042
|
+
|
|
1043
|
+
const memo = new WeakMap();
|
|
1044
|
+
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); };
|
|
1045
|
+
|
|
1046
|
+
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) }; } }
|
|
1047
|
+
|
|
1048
|
+
/** The resolved Haskell graph for a file set, or null when no explicit export is present. Cached per file set. */
|
|
1049
|
+
function resolvedHaskellGraph(files) {
|
|
1050
|
+
return memoized(files, 'hs-graph', () => {
|
|
1051
|
+
const planFile = Object.keys(files).find((p) => PLAN.test(p));
|
|
1052
|
+
const stackFile = !planFile ? Object.keys(files).find((p) => STACK_EXPORT.test(p)) : null;
|
|
1053
|
+
const src = planFile || stackFile;
|
|
1054
|
+
if (!src) return null;
|
|
1055
|
+
const mf = (0,haskell_supply/* manifestFiles */.TY)(files);
|
|
1056
|
+
const manifests = mf.length ? (0,haskell_manifests/* analyzeHaskellManifests */.JS)(mf) : null;
|
|
1057
|
+
const parsed = parseJson(files[src]);
|
|
1058
|
+
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 };
|
|
1059
|
+
const graph = (0,haskell_resolved_graph/* buildResolvedGraph */.xU)({ manifests, ...(planFile ? { plan: parsed.value } : { stackExport: parsed.value }), expected: {} });
|
|
1060
|
+
return { file: src, graph, manifests };
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Components for the resolved graph: every Hackage unit with an exact version, its scope and where it came from. Only a graph that is
|
|
1066
|
+
* not stale contributes; a stale one is returned as a gap (never as components), and a partial one says it is partial.
|
|
1067
|
+
*/
|
|
1068
|
+
function resolvedHackageComponents(files) {
|
|
1069
|
+
const r = resolvedHaskellGraph(files);
|
|
1070
|
+
if (!r) return { components: [], gaps: [], summary: null };
|
|
1071
|
+
const g = r.graph;
|
|
1072
|
+
const gaps = (g.gaps || []).map((x) => ({ kind: `resolved-${x.kind}`, detail: x.detail || x.message || x.kind, file: r.file }));
|
|
1073
|
+
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 };
|
|
1074
|
+
if (!g.graphAvailable || g.freshness.status === 'stale') return { components: [], gaps, summary };
|
|
1075
|
+
// 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
|
|
1076
|
+
// contradicts the freeze is stale or forged, and contributes no versions.
|
|
1077
|
+
const frozen = new Map(((r.manifests && r.manifests.lockedPackages) || []).filter((l) => l.name && l.version).map((l) => [l.name, l.version]));
|
|
1078
|
+
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)`);
|
|
1079
|
+
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 }; }
|
|
1080
|
+
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 });
|
|
1081
|
+
const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
|
|
1082
|
+
const direct = new Set((g.declared || []).map((d) => d.name));
|
|
1083
|
+
const components = [];
|
|
1084
|
+
for (const u of g.units) {
|
|
1085
|
+
if (u.origin === 'local' || u.origin === 'compiler' || !u.name || !u.version) continue; // the project itself and boot libraries are not dependencies to match
|
|
1086
|
+
const scopes = Array.isArray(u.scopes) && u.scopes.length ? u.scopes : ['runtime'];
|
|
1087
|
+
const scope = scopes.includes('runtime') ? 'runtime' : scopes[0];
|
|
1088
|
+
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 } });
|
|
1089
|
+
}
|
|
1090
|
+
return { components, gaps, summary };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ── Nix closure ──────────────────────────────────────────────────────────────────
|
|
1094
|
+
function loadNixExports(files, env = process.env) {
|
|
1095
|
+
const exports = []; const sources = []; const problems = []; const trust = new Set();
|
|
1096
|
+
const take = (label, text, origin) => {
|
|
1097
|
+
sources.push(label);
|
|
1098
|
+
const parsed = parseJson(text);
|
|
1099
|
+
if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: label, detail: parsed.reason }); return; }
|
|
1100
|
+
const j = parsed.value;
|
|
1101
|
+
trust.add(origin);
|
|
1102
|
+
// 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
|
|
1103
|
+
// checked against; what an export must match is derived by the scanner (below) or comes from the operator.
|
|
1104
|
+
if (j && Array.isArray(j.exports)) exports.push(...j.exports.filter((e) => e && typeof e === 'object'));
|
|
1105
|
+
else if (j && typeof j === 'object' && j.schema && (j.data !== undefined || j.text !== undefined)) exports.push(j);
|
|
1106
|
+
else exports.push({ text });
|
|
1107
|
+
};
|
|
1108
|
+
if (env.AGENTIC_SECURITY_NIX_EXPORT) {
|
|
1109
|
+
try { take(env.AGENTIC_SECURITY_NIX_EXPORT, (0,external_node_fs_.readFileSync)(env.AGENTIC_SECURITY_NIX_EXPORT, 'utf8'), 'operator'); }
|
|
1110
|
+
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}` }); }
|
|
1111
|
+
}
|
|
1112
|
+
for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) take(p, files[p], 'project-supplied');
|
|
1113
|
+
// 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
|
|
1114
|
+
// without the lock it claims to describe being the lock that is here).
|
|
1115
|
+
const expected = {};
|
|
1116
|
+
const lockPath = Object.keys(files).filter((p) => /(^|\/)flake\.lock$/.test(p)).sort((a, b) => a.length - b.length)[0];
|
|
1117
|
+
if (lockPath) expected.flakeLockSha256 = (0,external_node_crypto_.createHash)('sha256').update(files[lockPath]).digest('hex');
|
|
1118
|
+
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' });
|
|
1119
|
+
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' }); } }
|
|
1120
|
+
return { exports, sources, problems, expected, trust: [...trust] };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
|
|
1124
|
+
function nixClosureOf(files, { now = Date.now() } = {}) {
|
|
1125
|
+
return memoized(files, 'nix-closure', () => {
|
|
1126
|
+
const { exports, sources, problems, expected, trust } = loadNixExports(files);
|
|
1127
|
+
if (!sources.length) return null;
|
|
1128
|
+
const closure = importNixClosure({ exports, expected, now });
|
|
1129
|
+
const drvEnv = {};
|
|
1130
|
+
for (const ex of exports) {
|
|
1131
|
+
if (ex.schema !== 'nix-derivation-show-json') continue;
|
|
1132
|
+
let data = ex.data; if (data === undefined && typeof ex.text === 'string') { const p = parseJson(ex.text); data = p.ok ? p.value : null; }
|
|
1133
|
+
for (const [k, v] of Object.entries(data || {})) if (v && v.env) drvEnv[k] = v.env;
|
|
1134
|
+
}
|
|
1135
|
+
// A disclosure that says the export does not describe THIS project (another lock, another target, a bad or missing required
|
|
1136
|
+
// signature) means its contents are not evidence about this build: it is refused, not merely labelled.
|
|
1137
|
+
const REFUSE = new Set(['stale-export', 'foreign-target', 'foreign-revision', 'invalid-signature', 'unsigned']);
|
|
1138
|
+
const refused = (closure.disclosures || []).filter((d) => REFUSE.has(d.kind));
|
|
1139
|
+
return { closure, drvEnv, sources, problems, trust, refused };
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** Advisory records for Nix: a snapshot named by AGENTIC_SECURITY_NIX_ADVISORIES or kept in the project state directory. */
|
|
1144
|
+
function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
|
|
1145
|
+
const sel = (0,trusted_inputs/* resolveOperatorSnapshot */.Rn)({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
|
|
1146
|
+
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') : ''}` };
|
|
1147
|
+
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}` }; }
|
|
1148
|
+
const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
|
|
1149
|
+
return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Closure statuses and findings, with every disclosure the import and the feed produced. */
|
|
1153
|
+
function analyzeNixClosure(files, opts = {}) {
|
|
1154
|
+
return memoized(files, 'nix-analysis', () => _analyzeNixClosure(files, opts));
|
|
1155
|
+
}
|
|
1156
|
+
function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = Date.now() } = {}) {
|
|
1157
|
+
const c = nixClosureOf(files, { now });
|
|
1158
|
+
if (!c) return null;
|
|
1159
|
+
const hackage = (0,haskell_supply.configuredAdvisoryDb)(scanRoot, env).db;
|
|
1160
|
+
const adv = configuredNixAdvisories(scanRoot, env, hackage);
|
|
1161
|
+
let usage = null; try { usage = (0,haskell_supply/* collectUsage */.c$)(files); } catch { usage = null; }
|
|
1162
|
+
let matched = null;
|
|
1163
|
+
if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
|
|
1164
|
+
const gaps = [];
|
|
1165
|
+
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)' });
|
|
1166
|
+
for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
|
|
1167
|
+
for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
|
|
1168
|
+
for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
|
|
1169
|
+
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.` });
|
|
1170
|
+
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`})` });
|
|
1171
|
+
const findings = ((matched && matched.findings) || []).filter((f) => ACTIONABLE.has(f.status)).map((f) => ({ ...f, file: c.sources[0], line: 1 }));
|
|
1172
|
+
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 };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// ── isolated evaluation (opt in) ─────────────────────────────────────────────────
|
|
1176
|
+
/** Runs the isolated evaluation when AGENTIC_SECURITY_NIX_EVAL=1; otherwise null. Never throws; a failure is a recorded state. */
|
|
1177
|
+
async function runSelectedNixEval(root, env = process.env, overrides = {}) {
|
|
1178
|
+
if (env.AGENTIC_SECURITY_NIX_EVAL !== '1') return null;
|
|
1179
|
+
const attribute = env.AGENTIC_SECURITY_NIX_TARGET || '';
|
|
1180
|
+
const nixBin = overrides.nix || (which ? which('nix') : null);
|
|
1181
|
+
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 };
|
|
1182
|
+
if (!nixBin) return { status: 'unsupported', reason: 'the nix binary was not found on PATH; the project\'s Nix code was NOT evaluated', projectCodeEvaluated: false };
|
|
1183
|
+
try { return await runIsolatedEval({ nix: typeof nixBin === 'string' ? { file: nixBin } : nixBin, root, attribute, system: env.AGENTIC_SECURITY_NIX_SYSTEM || null, ...overrides }); }
|
|
1184
|
+
catch (e) { return { status: 'failed', reason: `the evaluation harness failed: ${String((e && e.message) || e).slice(0, 160)}`, projectCodeEvaluated: false }; }
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
/***/ })
|
|
1191
|
+
|
|
1192
|
+
};
|