@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.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 +34 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- 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 +1164 -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 +17 -2
- 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 +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -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,790 @@
|
|
|
1
|
+
// Nix fetch, build, cache and supply-chain trust rules (NIX-005).
|
|
2
|
+
//
|
|
3
|
+
// Two halves, one report:
|
|
4
|
+
//
|
|
5
|
+
// SOURCE fetcher calls, flake inputs and locks, script downloads, import-from-derivation
|
|
6
|
+
// and overlay relaxations, read from the parsed Nix AST (nix-parser.js).
|
|
7
|
+
// CONFIG nix.conf-level settings (trusted-users, signature checking, substituters, sandbox,
|
|
8
|
+
// native evaluation, IFD policy), judged on the EFFECTIVE configuration produced by
|
|
9
|
+
// nixos-module-resolver.js, never on raw text.
|
|
10
|
+
//
|
|
11
|
+
// Nothing here runs `nix`, evaluates a derivation, fetches a URL or executes an import: IFD and
|
|
12
|
+
// custom builders are reported as boundaries, not followed. Every finding is a supply-chain entry
|
|
13
|
+
// (`type: 'nix_build_trust'`, `ecosystem: 'nix'`), so SCA policy, SCA verdicts and scan health read
|
|
14
|
+
// it from the ordinary supplyChain bucket instead of a side channel.
|
|
15
|
+
//
|
|
16
|
+
// Three distinctions are kept apart on purpose:
|
|
17
|
+
// - LOCKED vs FLOATING. A flake input is locked when its URL carries a commit/narHash or a
|
|
18
|
+
// flake.lock node carries one; a floating ref (a branch) that the lock resolves is fine.
|
|
19
|
+
// A nixpkgs fetcher with a real hash is content-pinned even if its rev is a label.
|
|
20
|
+
// - APPLICABLE vs INERT hashes. A placeholder or empty hash is a finding only inside a fetcher or
|
|
21
|
+
// a fixed-output derivation (`outputHash`); the same text in any other attribute is just text.
|
|
22
|
+
// - SIGNED vs CONTENT-ADDRESSED. A binary-cache signature proves who produced an input-addressed
|
|
23
|
+
// path; it says nothing about vulnerabilities. Content-addressed and fixed-output paths are
|
|
24
|
+
// verified by their address and are never treated as unsigned input-addressed paths.
|
|
25
|
+
|
|
26
|
+
import { redactUrlsDeep } from './secrets.js';
|
|
27
|
+
import { parseNix, childrenOf } from './nix-parser.js';
|
|
28
|
+
import { buildNixIR } from './nix-ir.js';
|
|
29
|
+
import { loadNixGrammar } from './nix-grammar.js';
|
|
30
|
+
import { resolveNixosConfig } from './nixos-module-resolver.js';
|
|
31
|
+
import { normalizeRelease } from './nixos-option-catalog.js';
|
|
32
|
+
import { effectiveView as view, optionEvidence, optionAnchor } from './nixos-hardening.js';
|
|
33
|
+
import { isLanguageExcludedPath } from './discovery.js';
|
|
34
|
+
import { BUILD_TRUST_TYPE, isRegisteredLanguageProducer, registerLanguageProducer } from './contracts.js';
|
|
35
|
+
|
|
36
|
+
export { BUILD_TRUST_TYPE };
|
|
37
|
+
export const BUILD_TRUST_RULESET_VERSION = 'nix-build-trust/1';
|
|
38
|
+
export const NIX_BUILD_TRUST_PRODUCER = 'language:nix-build-trust';
|
|
39
|
+
|
|
40
|
+
const SEV_ORDER = ['info', 'low', 'medium', 'high', 'critical'];
|
|
41
|
+
const capSeverity = (sev, max) => (SEV_ORDER.indexOf(sev) > SEV_ORDER.indexOf(max) ? max : sev);
|
|
42
|
+
|
|
43
|
+
const CACHE_TRUST_BOUNDARY = Object.freeze({
|
|
44
|
+
boundary: 'binary-cache',
|
|
45
|
+
establishes: 'content integrity and signer identity of an input-addressed store path',
|
|
46
|
+
doesNotEstablish: 'that the software inside the path is free of known vulnerabilities (CVE absence)',
|
|
47
|
+
contentAddressed: 'content-addressed and fixed-output paths are verified by their address, not by a signature, and are not treated as unsigned input-addressed paths',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** `severity` is the BASE; `kind` says what sort of claim the finding is. */
|
|
51
|
+
export const BUILD_TRUST_RULES = Object.freeze({
|
|
52
|
+
'nix-fetch-missing-hash': { family: 'nix-fetch-integrity', kind: 'integrity', cwe: 'CWE-494', severity: 'high', ruleVersion: 1, vuln: 'Fetcher has no content hash', why: 'Without a hash the fetched bytes are not verified, so whatever the URL serves at build time becomes part of the build.', fix: 'Add the fetcher\'s hash (run the build once with lib.fakeHash and copy the reported value) or pin the source through a flake input and flake.lock.' },
|
|
53
|
+
'nix-fetch-fake-hash': { family: 'nix-fetch-integrity', kind: 'integrity', cwe: 'CWE-494', severity: 'medium', ruleVersion: 1, vuln: 'Fetcher or fixed-output derivation carries a placeholder hash', why: 'An empty, all-zero or lib.fake* hash is a stand-in for a value that was never recorded; committed, it means the source has no real integrity pin.', fix: 'Replace the placeholder with the hash Nix reports on the first build, and keep it in version control.' },
|
|
54
|
+
'nix-fetch-floating-rev': { family: 'nix-fetch-pin', kind: 'integrity', cwe: 'CWE-829', severity: 'high', ruleVersion: 1, vuln: 'Source revision is a moving reference', why: 'A branch name or an absent rev resolves to whatever the remote serves at that moment, so two evaluations can build different code. A hash pins the bytes, not the intent: with a hash this is only a reproducibility and update-review risk.', fix: 'Pin rev to a full commit hash (or a release tag together with its hash) and update it deliberately.' },
|
|
55
|
+
'nix-fetch-insecure-transport': { family: 'nix-fetch-integrity', kind: 'integrity', cwe: 'CWE-319', severity: 'medium', ruleVersion: 1, vuln: 'Fetch uses an unencrypted transport', why: 'http:// and git:// can be altered in transit. A hash makes that detectable but turns it into a build failure or silent downgrade risk when the hash is missing.', fix: 'Use https:// (or ssh) and keep the content hash.' },
|
|
56
|
+
'nix-flake-input-unlocked': { family: 'nix-flake-lock', kind: 'integrity', cwe: 'CWE-829', severity: 'medium', ruleVersion: 1, vuln: 'Flake input is not locked to a revision', why: 'An input that is neither pinned in its URL nor present in flake.lock is re-resolved on the next evaluation, so the inputs of the build can change without a reviewed diff.', fix: 'Run `nix flake lock`, commit flake.lock, or pin the input URL to a commit (github:owner/repo/<rev>).' },
|
|
57
|
+
'nix-flake-nixconfig-cache': { family: 'nix-cache-trust', kind: 'policy', cwe: 'CWE-494', severity: 'low', ruleVersion: 1, vuln: 'Flake proposes its own binary cache or signing key', why: 'nixConfig lets a flake ask the user to trust an extra substituter or public key. It only takes effect when accepted (or when accept-flake-config is true), but that acceptance extends trust to whoever controls the flake.', fix: 'Accept it only for flakes you trust, and prefer configuring caches and keys system-wide.' },
|
|
58
|
+
'nix-script-pipe-to-shell': { family: 'nix-script-download', kind: 'integrity', cwe: 'CWE-494', severity: 'high', ruleVersion: 1, vuln: 'Script pipes a download straight into a shell', why: 'The remote response is executed with no hash, signature or review step, so the server (or anyone on the path) decides what runs.', fix: 'Fetch the artifact with a hashed Nix fetcher and run the verified store path instead.' },
|
|
59
|
+
'nix-script-tls-verification-off': { family: 'nix-script-download', kind: 'integrity', cwe: 'CWE-295', severity: 'medium', ruleVersion: 1, vuln: 'Script downloads with TLS verification disabled', why: '-k/--insecure/--no-check-certificate accepts any certificate, which allows interception of the download.', fix: 'Remove the flag and provide the CA through the build or service trust store.' },
|
|
60
|
+
'nix-script-unverified-download': { family: 'nix-script-download', kind: 'integrity', cwe: 'CWE-494', severity: 'medium', ruleVersion: 1, vuln: 'Script downloads a file with no integrity check', why: 'A curl/wget in a script with no checksum comparison trusts the bytes it receives. In a sandboxed build the network is unavailable, so this is mostly a runtime-script concern.', fix: 'Compare a pinned checksum (sha256sum -c) before using the file, or fetch it with a hashed Nix fetcher.' },
|
|
61
|
+
'nix-ifd-boundary': { family: 'nix-eval-boundary', kind: 'boundary', cwe: 'CWE-829', severity: 'low', ruleVersion: 1, vuln: 'Import from derivation crosses an evaluation boundary', why: 'Evaluation depends on the output of a build or a fetch, so evaluating this expression runs builder code or reaches the network. That is a boundary and a reproducibility/policy concern; it is not by itself an exploitable vulnerability.', fix: 'If policy forbids IFD, generate the file ahead of time and commit it; otherwise review the builder and its inputs as you would any build.' },
|
|
62
|
+
'nix-ifd-policy-enabled': { family: 'nix-eval-boundary', kind: 'policy', cwe: 'CWE-829', severity: 'low', ruleVersion: 1, vuln: 'Import from derivation is explicitly enabled', why: 'allow-import-from-derivation = true lets evaluation trigger builds. It is the Nix default, so setting it explicitly records a policy choice rather than a defect.', fix: 'Set allow-import-from-derivation = false where evaluation must stay build-free (CI, hydra-style evaluators).' },
|
|
63
|
+
'nix-unsafe-native-eval': { family: 'nix-eval-native', kind: 'policy', cwe: 'CWE-94', severity: 'high', ruleVersion: 1, vuln: 'Unsafe native code is allowed during evaluation', why: 'allow-unsafe-native-code-during-evaluation lets expressions run native code in the evaluator process, outside the build sandbox.', fix: 'Remove the setting (default false).' },
|
|
64
|
+
'nix-eval-native-plugin': { family: 'nix-eval-native', kind: 'policy', cwe: 'CWE-94', severity: 'high', ruleVersion: 1, vuln: 'Evaluator loads native plugins', why: 'plugin-files loads shared libraries into the Nix evaluator and daemon, which then run with their privileges.', fix: 'Remove plugin-files unless the plugin is audited and required.' },
|
|
65
|
+
'nix-extra-builtins-file': { family: 'nix-eval-native', kind: 'policy', cwe: 'CWE-94', severity: 'medium', ruleVersion: 1, vuln: 'Evaluator loads extra builtins from a file', why: 'extra-builtins-file adds builtins (typically builtins.exec) that run commands at evaluation time.', fix: 'Remove the setting, or keep the file under review and owned by root.' },
|
|
66
|
+
'nix-trusted-users-widened': { family: 'nix-privilege', kind: 'policy', cwe: 'CWE-269', severity: 'medium', ruleVersion: 1, vuln: 'trusted-users grants daemon privileges beyond root', why: 'A trusted user can pass options such as extra substituters and import unsigned store paths through the daemon, which is equivalent to root on the build host.', fix: 'Leave trusted-users at ["root"] and use allowed-users or a deploy account for access.' },
|
|
67
|
+
'nix-require-sigs-disabled': { family: 'nix-cache-trust', kind: 'policy', cwe: 'CWE-347', severity: 'high', ruleVersion: 1, vuln: 'Binary-cache signature checking is disabled', why: 'With require-sigs = false the store accepts input-addressed paths from any substituter without verifying a signature, so a compromised or spoofed cache can supply arbitrary binaries.', fix: 'Remove require-sigs = false and list the signing keys of the caches you use in trusted-public-keys.' },
|
|
68
|
+
'nix-substituter-insecure-transport': { family: 'nix-cache-trust', kind: 'policy', cwe: 'CWE-319', severity: 'low', ruleVersion: 1, vuln: 'Binary cache is reached over plain http', why: 'Signatures keep the content intact, but plain http exposes which paths are fetched and lets a network attacker downgrade or withhold. With signature checking off it is a direct substitution risk.', fix: 'Use an https:// (or ssh://) substituter.' },
|
|
69
|
+
'nix-accept-flake-config': { family: 'nix-cache-trust', kind: 'policy', cwe: 'CWE-494', severity: 'medium', ruleVersion: 1, vuln: 'Flake-supplied configuration is accepted automatically', why: 'accept-flake-config = true lets every flake you evaluate add substituters and keys without a prompt.', fix: 'Remove accept-flake-config and accept individual flakes explicitly.' },
|
|
70
|
+
'nix-sandbox-disabled': { family: 'nix-sandbox', kind: 'policy', cwe: 'CWE-693', severity: 'high', ruleVersion: 1, vuln: 'Build sandbox is disabled or relaxed', why: 'Without the sandbox a build can read the host filesystem and reach the network, so a malicious or compromised derivation is not contained.', fix: 'Set sandbox = true (the default) and grant specific paths with extra-sandbox-paths if one build needs them.' },
|
|
71
|
+
'nix-sandbox-sensitive-path': { family: 'nix-sandbox', kind: 'policy', cwe: 'CWE-732', severity: 'high', ruleVersion: 1, vuln: 'Sandbox exposes a sensitive host path to builds', why: 'extra-sandbox-paths mounts host paths into every build; a broad directory or a runtime socket hands builders the host.', fix: 'Expose only the single file a build needs, read-only, and never a runtime socket.' },
|
|
72
|
+
'nix-build-users-group-empty': { family: 'nix-sandbox', kind: 'policy', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'Builds run as the invoking user', why: 'An empty build-users-group makes the daemon run builders as the calling user instead of an unprivileged build account.', fix: 'Leave build-users-group at its default (nixbld).' },
|
|
73
|
+
'nix-overlay-hardening-disabled': { family: 'nix-overlay', kind: 'policy', cwe: 'CWE-693', severity: 'medium', ruleVersion: 1, vuln: 'Overlay disables compiler hardening', why: 'hardeningDisable in an overlay removes stack protector, fortify, PIE or RELRO from every consumer of the overridden package.', fix: 'Disable only the single flag a build needs, for that package, with a comment explaining why.' },
|
|
74
|
+
'nix-overlay-clears-vuln-marker': { family: 'nix-overlay', kind: 'policy', cwe: 'CWE-1395', severity: 'medium', ruleVersion: 1, vuln: 'Overlay clears a package\'s knownVulnerabilities', why: 'Resetting meta.knownVulnerabilities to [] removes the guard nixpkgs uses to refuse an insecure package.', fix: 'Remove the override; pin a fixed version, or list the package in permittedInsecurePackages with a dated justification.' },
|
|
75
|
+
'nix-insecure-packages-permitted': { family: 'nix-overlay', kind: 'policy', cwe: 'CWE-1395', severity: 'low', ruleVersion: 1, vuln: 'Insecure packages are permitted', why: 'permittedInsecurePackages / allowInsecure waive the nixpkgs refusal to build packages marked insecure.', fix: 'Prefer upgrading; keep each permitted package named, not allowInsecure = true, and review the list regularly.' },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── small AST helpers ──────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
|
|
81
|
+
|
|
82
|
+
function appChain(node) {
|
|
83
|
+
const args = [];
|
|
84
|
+
let fn = node;
|
|
85
|
+
while (fn && fn.type === 'app') { args.unshift(fn.arg); fn = unparen(fn.fn); }
|
|
86
|
+
return { fn, args };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `foo`, `lib.foo`, `pkgs.lib.foo`, `builtins.foo` -> 'foo'. */
|
|
90
|
+
function lastName(node) {
|
|
91
|
+
const f = unparen(node);
|
|
92
|
+
if (!f) return null;
|
|
93
|
+
if (f.type === 'ident') return f.name;
|
|
94
|
+
if (f.type === 'select' && f.attrpath.length) {
|
|
95
|
+
const last = f.attrpath[f.attrpath.length - 1];
|
|
96
|
+
if (last.kind === 'static') return last.name;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const strLiteral = (n) => { const x = unparen(n); return x && x.type === 'string' && !x.interpolated ? x.literal : undefined; };
|
|
102
|
+
|
|
103
|
+
/** Static, single-segment attributes of an attrset: name -> { node, span }. */
|
|
104
|
+
function attrsOf(set) {
|
|
105
|
+
const map = new Map();
|
|
106
|
+
for (const b of set.bindings) {
|
|
107
|
+
if (b.kind === 'attr') {
|
|
108
|
+
if (b.path.length === 1 && b.path[0].kind === 'static') map.set(b.path[0].name, { node: b.value, span: b.span });
|
|
109
|
+
} else if (b.kind === 'inherit' && !b.from) {
|
|
110
|
+
for (const nm of b.names) if (nm.kind === 'static') map.set(nm.name, { node: null, span: b.span, inherited: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return map;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const HASH_ATTRS = ['hash', 'sha256', 'sha512', 'sha1', 'outputHash', 'narHash'];
|
|
117
|
+
const FAKE_NAMES = new Set(['fakeHash', 'fakeSha256', 'fakeSha512', 'fakeSha1']);
|
|
118
|
+
const COMMIT_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
119
|
+
|
|
120
|
+
function isFakeHash(node) {
|
|
121
|
+
const n = unparen(node);
|
|
122
|
+
if (!n) return false;
|
|
123
|
+
const lit = strLiteral(n);
|
|
124
|
+
if (lit !== undefined) {
|
|
125
|
+
return lit === '' || /^sha256-A{43}=$/.test(lit) || /^sha512-A{86}==$/.test(lit) || /^sha1-A{27}=$/.test(lit) || /^0{32,}$/.test(lit);
|
|
126
|
+
}
|
|
127
|
+
if (n.type === 'ident' || n.type === 'select') return FAKE_NAMES.has(lastName(n));
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hashState(attrs) {
|
|
132
|
+
for (const k of HASH_ATTRS) {
|
|
133
|
+
const a = attrs.get(k);
|
|
134
|
+
if (!a) continue;
|
|
135
|
+
if (!a.node) return { state: 'dynamic', attr: k, span: a.span };
|
|
136
|
+
if (isFakeHash(a.node)) return { state: 'fake', attr: k, span: unparen(a.node).span };
|
|
137
|
+
const n = unparen(a.node);
|
|
138
|
+
if (n.type === 'string' && !n.interpolated) return { state: 'present', attr: k, span: n.span };
|
|
139
|
+
return { state: 'dynamic', attr: k, span: n.span };
|
|
140
|
+
}
|
|
141
|
+
return { state: 'missing' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function revKind(attrs) {
|
|
145
|
+
const rev = attrs.get('rev');
|
|
146
|
+
if (!rev) return attrs.get('tag') ? { kind: 'tag' } : { kind: 'none' };
|
|
147
|
+
if (!rev.node) return { kind: 'dynamic' };
|
|
148
|
+
const lit = strLiteral(rev.node);
|
|
149
|
+
if (lit === undefined) return { kind: 'dynamic' };
|
|
150
|
+
return COMMIT_RE.test(lit) ? { kind: 'commit', value: lit } : { kind: 'ref', value: lit };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// callee name -> how the fetch is judged
|
|
154
|
+
const FETCHERS = {
|
|
155
|
+
fetchurl: 'fod', fetchzip: 'fod', fetchFromGitHub: 'fod', fetchFromGitLab: 'fod', fetchFromSourcehut: 'fod',
|
|
156
|
+
fetchFromBitbucket: 'fod', fetchFromGitea: 'fod', fetchgit: 'fod', fetchpatch: 'fod', fetchpatch2: 'fod',
|
|
157
|
+
fetchsvn: 'fod', fetchhg: 'fod', fetchTarball: 'fod',
|
|
158
|
+
fetchGit: 'git', fetchTree: 'tree',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const BUILD_CALLEES = new Set(['runCommand', 'runCommandLocal', 'runCommandNoCC', 'mkDerivation', 'derivation', 'stdenv', 'buildPythonPackage', 'buildGoModule', 'buildRustPackage', 'buildNpmPackage', 'writeText', 'writeTextFile', 'writeShellScript', 'writeShellScriptBin', 'writeShellApplication', 'symlinkJoin', 'linkFarm']);
|
|
162
|
+
const CABAL_IFD = new Set(['callCabal2nix', 'callCabal2nixWithOptions', 'callHackage', 'callHackageDirect', 'importCabal']);
|
|
163
|
+
const SCRIPT_BUILDERS = new Set(['writeShellScript', 'writeShellScriptBin', 'writeScript', 'writeScriptBin', 'writeShellApplication', 'runCommand', 'runCommandLocal', 'runCommandNoCC']);
|
|
164
|
+
const SCRIPT_ATTR = /^(?:\w*Phase|\w*Hook|\w*[sS]cript|text|Exec\w+|(?:pre|post)[A-Z]\w*|\w*Commands?|\w*Init|\w*Setup)$/;
|
|
165
|
+
|
|
166
|
+
const PIPE_TO_SHELL = /\b(?:curl|wget|fetch)\b[^\n|]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:ba|z|da|k)?sh\b|\b(?:ba|z|da)?sh\s+<\(\s*(?:curl|wget)\b|\beval\s+["']?\$\(\s*(?:curl|wget)\b|\bsh\s+-c\s+["']\$\(\s*(?:curl|wget)\b/;
|
|
167
|
+
const TLS_OFF_FLAG = /\b(?:curl|wget)\b[^\n]*?\s(?:-k|--insecure|--no-check-certificate)(?:\s|$)/;
|
|
168
|
+
const DOWNLOAD = /\b(?:curl|wget)\b[^\n]*https?:\/\//;
|
|
169
|
+
const VERIFY = /\b(?:sha(?:1|224|256|384|512)sum|shasum|b3sum|nix-hash|nix\s+hash|openssl\s+dgst|gpg\s+--verify|cosign\s+verify|minisign)\b/;
|
|
170
|
+
|
|
171
|
+
const HARDENING_FLAGS = new Set(['all', 'stackprotector', 'fortify', 'fortify3', 'pie', 'relro', 'bindnow', 'stackclashprotection', 'format']);
|
|
172
|
+
|
|
173
|
+
const FINAL_NAMES = new Set(['final', 'self', 'pkgsFinal', '_final']);
|
|
174
|
+
const PREV_NAMES = new Set(['prev', 'super', 'pkgsPrev', '_prev']);
|
|
175
|
+
|
|
176
|
+
const SENSITIVE_PATHS = [/^\/$/, /^\/(?:etc|home|root|var|run|usr|boot|dev|proc|sys)$/, /docker\.sock$/, /podman\.sock$/, /containerd\.sock$/, /^\/nix\/var\/nix\/daemon-socket/, /^\/run\/secrets/, /^\/etc\/(?:shadow|ssh)/];
|
|
177
|
+
|
|
178
|
+
// ── settings (nix.conf) reading ────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const LEGACY = {
|
|
181
|
+
'require-sigs': ['nix.requireSignedBinaryCaches'],
|
|
182
|
+
'substituters': ['nix.binaryCaches'],
|
|
183
|
+
'trusted-substituters': ['nix.trustedBinaryCaches'],
|
|
184
|
+
'trusted-public-keys': ['nix.binaryCachePublicKeys'],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const LIST_KEYS = new Set(['trusted-users', 'substituters', 'trusted-substituters', 'trusted-public-keys', 'sandbox-paths', 'extra-sandbox-paths', 'plugin-files', 'experimental-features']);
|
|
188
|
+
|
|
189
|
+
function parseExtraOptions(text) {
|
|
190
|
+
const out = new Map();
|
|
191
|
+
for (const raw of String(text).split('\n')) {
|
|
192
|
+
const line = raw.replace(/(^|\s)#.*$/, '').trim();
|
|
193
|
+
const m = /^([A-Za-z0-9-]+)\s*=\s*(.*)$/.exec(line);
|
|
194
|
+
if (!m) continue;
|
|
195
|
+
const tokens = m[2].split(/\s+/).filter(Boolean);
|
|
196
|
+
out.set(m[1], tokens);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const scalarOf = (tokens) => {
|
|
202
|
+
const t = tokens.length === 1 ? tokens[0] : tokens.join(' ');
|
|
203
|
+
if (t === 'true') return true;
|
|
204
|
+
if (t === 'false') return false;
|
|
205
|
+
return t;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
function readSetting(cfg, key, extra) {
|
|
209
|
+
const list = LIST_KEYS.has(key);
|
|
210
|
+
const names = list ? [key, `extra-${key}`] : [key];
|
|
211
|
+
const parts = [];
|
|
212
|
+
for (const n of names) parts.push({ option: `nix.settings.${n}`, res: cfg.lookup(`nix.settings.${n}`) });
|
|
213
|
+
for (const l of LEGACY[key] || []) parts.push({ option: l, res: cfg.lookup(l), legacy: true });
|
|
214
|
+
const present = parts.filter((p) => p.res && ((p.res.sources || []).length > 0 || ((p.res.status === 'default') && p.res.valueKnown)));
|
|
215
|
+
const s = { key, state: 'absent', list, values: [], items: [], possible: [], possibleItems: [], evidence: [], anchors: [], definite: true, viaText: false };
|
|
216
|
+
let cond = false;
|
|
217
|
+
let unknown = false;
|
|
218
|
+
for (const p of present) {
|
|
219
|
+
const v = view(p.res);
|
|
220
|
+
s.evidence.push(optionEvidence(p.res));
|
|
221
|
+
if ((p.res.sources || []).length) s.anchors.push(optionAnchor(p.res));
|
|
222
|
+
if (v.state === 'known') {
|
|
223
|
+
s.values.push(v.value);
|
|
224
|
+
if (list && Array.isArray(v.value)) s.items.push(...v.value);
|
|
225
|
+
if (!v.definite && v.via !== 'catalog-default') s.definite = false;
|
|
226
|
+
} else if (v.state === 'conditional') {
|
|
227
|
+
cond = true;
|
|
228
|
+
s.definite = false;
|
|
229
|
+
s.possible.push(...v.possible);
|
|
230
|
+
s.possibleItems.push(...(v.possibleItems || []), ...(v.definiteItems || []));
|
|
231
|
+
if (list) s.items.push(...(v.definiteItems || []));
|
|
232
|
+
} else { unknown = true; s.definite = false; }
|
|
233
|
+
}
|
|
234
|
+
// nix.extraOptions is appended to nix.conf after nix.settings, so a later line wins for scalars
|
|
235
|
+
if (extra && extra.text) {
|
|
236
|
+
const eo = extra.text;
|
|
237
|
+
for (const n of names) {
|
|
238
|
+
if (!eo.lines.has(n)) continue;
|
|
239
|
+
const tokens = eo.lines.get(n);
|
|
240
|
+
s.viaText = true;
|
|
241
|
+
s.evidence.push({ ...eo.evidence, textKey: n, locatedAt: 'option' });
|
|
242
|
+
s.anchors.push(eo.anchor);
|
|
243
|
+
if (list) { s.values.push(tokens); s.items.push(...tokens); } else s.values.push(scalarOf(tokens));
|
|
244
|
+
if (!eo.definite) s.definite = false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (cond) s.state = 'conditional';
|
|
248
|
+
else if (s.values.length) s.state = 'known';
|
|
249
|
+
else if (unknown) s.state = 'unknown';
|
|
250
|
+
if (s.values.length && !cond) s.value = s.values[s.values.length - 1];
|
|
251
|
+
return s;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 'yes' | 'conditional' | 'no' | 'unknown' | 'absent' for a predicate over the setting's value (or items). */
|
|
255
|
+
function holds(s, pred) {
|
|
256
|
+
if (s.state === 'absent') return 'absent';
|
|
257
|
+
if (s.state === 'unknown') return 'unknown';
|
|
258
|
+
const known = s.list ? s.items : s.values.slice(-1);
|
|
259
|
+
if (known.some((x) => pred(x))) return 'yes';
|
|
260
|
+
if (s.state === 'conditional') return (s.list ? s.possibleItems : s.possible).some((x) => pred(x)) ? 'conditional' : 'no';
|
|
261
|
+
return 'no';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const spanLine = (sp) => (sp && Number.isInteger(sp.startLine) ? sp.startLine : 1);
|
|
265
|
+
|
|
266
|
+
// ── analysis ───────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {object} opts
|
|
270
|
+
* @param {Record<string,string>} opts.files rel path -> content (Nix sources, optionally flake.lock)
|
|
271
|
+
* @param {string} [opts.entry] NixOS module entry (default configuration.nix when present)
|
|
272
|
+
* @param {object} [opts.target] { release, system, args } as for resolveNixosConfig
|
|
273
|
+
*/
|
|
274
|
+
export function analyzeNixBuildTrust(opts = {}) {
|
|
275
|
+
const files = opts.files || {};
|
|
276
|
+
const findings = [];
|
|
277
|
+
const controls = [];
|
|
278
|
+
const gaps = [];
|
|
279
|
+
const inventory = { fetches: [], flakeInputs: [], ifd: [], contentAddressed: [] };
|
|
280
|
+
let context = { target: opts.target || null, release: null, catalog: null };
|
|
281
|
+
|
|
282
|
+
const addFinding = (rule, { severity, file, span, name, subject, evidence, state = 'active', uncertain = false, partial = false, evidenceKind = 'source', capability = 'sca', extra = {}, line, trustBoundary }) => {
|
|
283
|
+
const r = BUILD_TRUST_RULES[rule];
|
|
284
|
+
let sev = severity || r.severity;
|
|
285
|
+
const conditional = state === 'conditional';
|
|
286
|
+
const uncertainty = [];
|
|
287
|
+
if (conditional) uncertainty.push({ kind: 'unresolved-branch', detail: 'the value depends on a condition that could not be decided statically' });
|
|
288
|
+
if (partial) uncertainty.push({ kind: 'unresolved-import', detail: 'the module graph is partial; an unread module could override this value' });
|
|
289
|
+
if (uncertain && !conditional && !partial) uncertainty.push({ kind: 'unresolved-branch', detail: 'the effective value carries evaluation caveats' });
|
|
290
|
+
if (extra.uncertainty) uncertainty.push(...extra.uncertainty);
|
|
291
|
+
if (conditional || partial || uncertain) sev = capSeverity(sev, 'medium');
|
|
292
|
+
const ln = Number.isInteger(line) ? line : spanLine(span);
|
|
293
|
+
const { uncertainty: _u, ...rest } = extra;
|
|
294
|
+
void _u;
|
|
295
|
+
findings.push({
|
|
296
|
+
id: `nix-build-trust:${rule}:${file || 'config'}:${subject}`,
|
|
297
|
+
type: BUILD_TRUST_TYPE, ecosystem: 'nix', name: name || subject, version: null, subject,
|
|
298
|
+
severity: sev, file: file || null, line: ln,
|
|
299
|
+
vuln: r.vuln, cwe: r.cwe,
|
|
300
|
+
description: `${r.vuln} (${subject}). ${r.why}`,
|
|
301
|
+
remediation: r.fix, remediationRationale: r.why,
|
|
302
|
+
parser: 'nix-build-trust', family: r.family,
|
|
303
|
+
language: 'nix', capability, evidenceKind,
|
|
304
|
+
scope: { target: context.target && context.target.system ? String(context.target.system) : null, configuration: opts.entry || null, component: subject },
|
|
305
|
+
originalLocation: { file: file || undefined, line: ln, column: span && Number.isInteger(span.startColumn) ? span.startColumn : 0 },
|
|
306
|
+
rule, ruleVersion: r.ruleVersion, rulesetVersion: BUILD_TRUST_RULESET_VERSION, baseSeverity: r.severity, kind: r.kind,
|
|
307
|
+
conditional, uncertainty: uncertainty.length ? uncertainty : undefined,
|
|
308
|
+
evidence, context: { ...context }, trustBoundary,
|
|
309
|
+
...rest,
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// ── SOURCE half ──
|
|
314
|
+
const lockCache = new Map();
|
|
315
|
+
const lockFor = (flakeFile) => {
|
|
316
|
+
const dir = flakeFile.includes('/') ? flakeFile.slice(0, flakeFile.lastIndexOf('/')) : '';
|
|
317
|
+
const lockPath = dir ? `${dir}/flake.lock` : 'flake.lock';
|
|
318
|
+
if (lockCache.has(lockPath)) return lockCache.get(lockPath);
|
|
319
|
+
let out = { present: false, path: lockPath };
|
|
320
|
+
if (typeof files[lockPath] === 'string') {
|
|
321
|
+
try {
|
|
322
|
+
const doc = JSON.parse(files[lockPath]);
|
|
323
|
+
out = { present: true, path: lockPath, nodes: doc.nodes || {}, root: doc.root || 'root' };
|
|
324
|
+
} catch (e) {
|
|
325
|
+
out = { present: true, path: lockPath, invalid: String(e.message || e) };
|
|
326
|
+
gaps.push({ kind: 'invalid-flake-lock', detail: `flake.lock could not be parsed: ${out.invalid}`, file: lockPath, line: 1 });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
lockCache.set(lockPath, out);
|
|
330
|
+
return out;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
function analyzeSource(file, text) {
|
|
334
|
+
const parse = parseNix(text, { file });
|
|
335
|
+
if (!parse.ast) return;
|
|
336
|
+
const ir = buildNixIR(parse, { file, source: text });
|
|
337
|
+
const fileOverlay = ir.fileKind === 'overlay';
|
|
338
|
+
const innerApps = new WeakSet();
|
|
339
|
+
const handledSets = new WeakSet();
|
|
340
|
+
const scriptStrings = new WeakSet();
|
|
341
|
+
|
|
342
|
+
const fetchEntry = (callee, span, extraEntry) => {
|
|
343
|
+
const e = { callee, file, line: spanLine(span), ...extraEntry };
|
|
344
|
+
inventory.fetches.push(e);
|
|
345
|
+
return e;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
function handleFetch(name, kind, args, call) {
|
|
349
|
+
const last = unparen(args[args.length - 1]);
|
|
350
|
+
const span = call.span;
|
|
351
|
+
if (last && last.type === 'string') {
|
|
352
|
+
// shorthand: fetchTarball "url" / fetchGit "url" carries no hash and no rev
|
|
353
|
+
const url = last.interpolated ? null : last.literal;
|
|
354
|
+
if (kind === 'fod') {
|
|
355
|
+
fetchEntry(name, span, { url, hash: 'missing', locked: false });
|
|
356
|
+
addFinding('nix-fetch-missing-hash', { file, span, subject: `${name}@${spanLine(span)}`, name: url || name, evidence: { callee: name, url, form: 'string-shorthand' }, extra: { fetcher: name } });
|
|
357
|
+
} else if (kind === 'git') {
|
|
358
|
+
fetchEntry(name, span, { url, rev: 'none', locked: false });
|
|
359
|
+
addFinding('nix-fetch-floating-rev', { file, span, subject: `${name}@${spanLine(span)}`, name: url || name, evidence: { callee: name, url, form: 'string-shorthand', rev: null }, extra: { fetcher: name } });
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (!last || last.type !== 'attrset') {
|
|
364
|
+
fetchEntry(name, span, { hash: 'unknown', locked: 'unknown' });
|
|
365
|
+
gaps.push({ kind: 'dynamic-fetch-arguments', detail: `${name} arguments are not a literal attribute set, so its hash and revision were not judged`, file, line: spanLine(span) });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
handledSets.add(last);
|
|
369
|
+
const attrs = attrsOf(last);
|
|
370
|
+
const hs = hashState(attrs);
|
|
371
|
+
const rk = revKind(attrs);
|
|
372
|
+
const urlAttr = attrs.get('url');
|
|
373
|
+
const url = urlAttr && urlAttr.node ? strLiteral(urlAttr.node) : undefined;
|
|
374
|
+
const ownerRepo = attrs.get('owner') && attrs.get('repo') ? `${strLiteral(attrs.get('owner').node) ?? '?'}/${strLiteral(attrs.get('repo').node) ?? '?'}` : null;
|
|
375
|
+
const label = url || ownerRepo || name;
|
|
376
|
+
const base = { callee: name, url: url ?? null, repo: ownerRepo, hashAttr: hs.attr || null };
|
|
377
|
+
const subject = `${name}@${spanLine(span)}`;
|
|
378
|
+
|
|
379
|
+
if (kind === 'fod') {
|
|
380
|
+
const entry = fetchEntry(name, span, { url: url ?? null, repo: ownerRepo, hash: hs.state, rev: rk.kind, locked: hs.state === 'present' ? true : hs.state === 'dynamic' ? 'unknown' : false });
|
|
381
|
+
if (hs.state === 'missing') {
|
|
382
|
+
addFinding('nix-fetch-missing-hash', { file, span, subject, name: label, evidence: { ...base, hash: 'missing', revKind: rk.kind }, extra: { fetcher: name } });
|
|
383
|
+
} else if (hs.state === 'fake') {
|
|
384
|
+
addFinding('nix-fetch-fake-hash', { file, span: hs.span, subject, name: label, evidence: { ...base, hash: 'placeholder', context: 'fetcher' }, extra: { fetcher: name } });
|
|
385
|
+
} else if (hs.state === 'dynamic') {
|
|
386
|
+
gaps.push({ kind: 'dynamic-fetch-hash', detail: `${name} hash is not a literal, so its value was not judged`, file, line: spanLine(hs.span) });
|
|
387
|
+
} else if (rk.kind === 'ref') {
|
|
388
|
+
addFinding('nix-fetch-floating-rev', { file, span, subject, name: label, severity: 'low', evidence: { ...base, rev: rk.value, hash: 'present', contentPinned: true }, extra: { fetcher: name, contentPinned: true } });
|
|
389
|
+
}
|
|
390
|
+
if (typeof url === 'string' && /^(?:http|git):\/\//i.test(url)) {
|
|
391
|
+
addFinding('nix-fetch-insecure-transport', { file, span, subject, name: label, severity: hs.state === 'present' ? 'low' : 'high', evidence: { ...base, hash: hs.state }, extra: { fetcher: name, contentPinned: hs.state === 'present' } });
|
|
392
|
+
}
|
|
393
|
+
void entry;
|
|
394
|
+
} else if (kind === 'git') {
|
|
395
|
+
const locked = rk.kind === 'commit' ? true : rk.kind === 'dynamic' ? 'unknown' : false;
|
|
396
|
+
fetchEntry(name, span, { url: url ?? null, rev: rk.kind, locked });
|
|
397
|
+
if (locked === false) addFinding('nix-fetch-floating-rev', { file, span, subject, name: label, evidence: { ...base, rev: rk.value ?? null, revKind: rk.kind }, extra: { fetcher: name, contentPinned: false } });
|
|
398
|
+
else if (locked === 'unknown') gaps.push({ kind: 'dynamic-fetch-rev', detail: `${name} rev is computed, so its pin was not judged`, file, line: spanLine(span) });
|
|
399
|
+
} else if (kind === 'tree') {
|
|
400
|
+
const pinned = hs.state === 'present' || rk.kind === 'commit';
|
|
401
|
+
const locked = pinned ? true : hs.state === 'dynamic' || rk.kind === 'dynamic' ? 'unknown' : false;
|
|
402
|
+
fetchEntry(name, span, { url: url ?? null, hash: hs.state, rev: rk.kind, locked });
|
|
403
|
+
if (locked === false) addFinding('nix-fetch-floating-rev', { file, span, subject, name: label, evidence: { ...base, hash: hs.state, revKind: rk.kind }, extra: { fetcher: name, contentPinned: false } });
|
|
404
|
+
else if (locked === 'unknown') gaps.push({ kind: 'dynamic-fetch-rev', detail: `${name} pin is computed, so it was not judged`, file, line: spanLine(span) });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// name -> value node, only where the binding is unambiguous within this file (IFD origin tracking)
|
|
409
|
+
const env = new Map();
|
|
410
|
+
const ambiguous = new Set();
|
|
411
|
+
{
|
|
412
|
+
const stack = [parse.ast];
|
|
413
|
+
let guard = 0;
|
|
414
|
+
while (stack.length && guard++ < 50_000) {
|
|
415
|
+
const n = stack.pop();
|
|
416
|
+
if (n.type === 'attrset' || n.type === 'let') {
|
|
417
|
+
for (const b of n.bindings) {
|
|
418
|
+
if (b.kind === 'attr' && b.path.length === 1 && b.path[0].kind === 'static') {
|
|
419
|
+
const k = b.path[0].name;
|
|
420
|
+
if (env.has(k)) ambiguous.add(k); else env.set(k, b.value);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
for (const k of childrenOf(n)) stack.push(k);
|
|
425
|
+
}
|
|
426
|
+
for (const k of ambiguous) env.delete(k);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** What produces the value of `node`? 'build' | 'fetch' | 'cabal' | null (unknown / plain). */
|
|
430
|
+
function origin(node, depth = 0) {
|
|
431
|
+
const n = unparen(node);
|
|
432
|
+
if (!n || depth > 5) return null;
|
|
433
|
+
if (n.type === 'app') {
|
|
434
|
+
const { fn, args } = appChain(n);
|
|
435
|
+
const name = lastName(fn);
|
|
436
|
+
if (name && CABAL_IFD.has(name)) return 'cabal';
|
|
437
|
+
// builtins.fetch* run in the evaluator by design and are judged by the fetch rules, not as IFD
|
|
438
|
+
if (name === 'fetchTarball' || name === 'fetchGit' || name === 'fetchTree') return null;
|
|
439
|
+
if (name && FETCHERS[name]) return 'fetch';
|
|
440
|
+
if (name && BUILD_CALLEES.has(name)) return 'build';
|
|
441
|
+
if (name === 'overrideAttrs' || name === 'override') return origin(fn.type === 'select' ? fn.base : fn, depth + 1);
|
|
442
|
+
void args;
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
if (n.type === 'ident') return env.has(n.name) ? origin(env.get(n.name), depth + 1) : null;
|
|
446
|
+
if (n.type === 'select') return origin(n.base, depth + 1);
|
|
447
|
+
if (n.type === 'binop' && n.op === '+') return origin(n.left, depth + 1) || origin(n.right, depth + 1);
|
|
448
|
+
if (n.type === 'string' && n.interpolated) {
|
|
449
|
+
for (const p of n.parts) if (p.kind === 'interp') { const o = origin(p.expr, depth + 1); if (o) return o; }
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function handleIfd(name, args, call, viaImport) {
|
|
455
|
+
const target = args[0];
|
|
456
|
+
const o = origin(target);
|
|
457
|
+
if (!o) return;
|
|
458
|
+
const cls = o === 'fetch' ? 'resolution-boundary' : 'execution-boundary';
|
|
459
|
+
const span = call.span;
|
|
460
|
+
inventory.ifd.push({ file, line: spanLine(span), via: viaImport, origin: o, classification: cls });
|
|
461
|
+
addFinding('nix-ifd-boundary', {
|
|
462
|
+
file, span, subject: `${viaImport}@${spanLine(span)}`, name: viaImport,
|
|
463
|
+
severity: o === 'fetch' ? 'info' : 'low',
|
|
464
|
+
evidence: { via: viaImport, derivedFrom: o, classification: cls, executed: false },
|
|
465
|
+
extra: { ifd: { classification: cls, derivedFrom: o, executed: false, exploitabilityAssumed: false } },
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function handleCall(call) {
|
|
470
|
+
const { fn, args } = appChain(call);
|
|
471
|
+
const name = lastName(fn);
|
|
472
|
+
if (!name || !args.length) return;
|
|
473
|
+
const f = unparen(fn);
|
|
474
|
+
if (FETCHERS[name]) { handleFetch(name, FETCHERS[name], args, call); return; }
|
|
475
|
+
if (f && f.type === 'ident' && f.name === 'import') { handleIfd('import', args, call, 'import'); return; }
|
|
476
|
+
if (name === 'readFile' || name === 'readDir') { handleIfd(name, args, call, name); return; }
|
|
477
|
+
if (CABAL_IFD.has(name)) {
|
|
478
|
+
const span = call.span;
|
|
479
|
+
inventory.ifd.push({ file, line: spanLine(span), via: name, origin: 'cabal', classification: 'execution-boundary' });
|
|
480
|
+
addFinding('nix-ifd-boundary', { file, span, subject: `${name}@${spanLine(span)}`, name, severity: 'low', evidence: { via: name, derivedFrom: 'cabal', classification: 'execution-boundary', executed: false }, extra: { ifd: { classification: 'execution-boundary', derivedFrom: 'cabal', executed: false, exploitabilityAssumed: false } } });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (SCRIPT_BUILDERS.has(name)) for (const a of args) { const s = unparen(a); if (s && s.type === 'string') scriptStrings.add(s); }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function handleString(node, attr) {
|
|
487
|
+
const isScript = scriptStrings.has(node) || (attr && SCRIPT_ATTR.test(attr));
|
|
488
|
+
if (!isScript || node.parts.length === 0) return;
|
|
489
|
+
const text2 = node.parts.map((p) => (p.kind === 'text' ? p.value : '\u0001')).join('');
|
|
490
|
+
const shift = /^''[ \t]*\n/.test(text.slice(node.start, node.start + 8)) ? 1 : 0;
|
|
491
|
+
const lineAt = (idx) => node.span.startLine + shift + (text2.slice(0, idx).match(/\n/g) || []).length;
|
|
492
|
+
const ctx = scriptStrings.has(node) || /Phase$|^(?:pre|post)[A-Z]/.test(attr || '') ? 'build-time' : 'runtime';
|
|
493
|
+
const pipe = PIPE_TO_SHELL.exec(text2);
|
|
494
|
+
if (pipe) {
|
|
495
|
+
const ln = lineAt(pipe.index);
|
|
496
|
+
addFinding('nix-script-pipe-to-shell', { file, line: ln, span: node.span, subject: `${attr || 'script'}@${ln - ((node.span && node.span.startLine) || 0)}`, name: attr || 'script', severity: ctx === 'build-time' ? 'medium' : 'high', evidence: { attribute: attr || null, context: ctx, sandboxNote: ctx === 'build-time' ? 'a sandboxed build has no network, so this runs only if the sandbox is off or the derivation is fixed-output' : undefined } });
|
|
497
|
+
}
|
|
498
|
+
const tls = TLS_OFF_FLAG.exec(text2);
|
|
499
|
+
if (tls) {
|
|
500
|
+
const ln = lineAt(tls.index);
|
|
501
|
+
addFinding('nix-script-tls-verification-off', { file, line: ln, span: node.span, subject: `${attr || 'script'}@${ln - ((node.span && node.span.startLine) || 0)}`, name: attr || 'script', evidence: { attribute: attr || null, context: ctx } });
|
|
502
|
+
}
|
|
503
|
+
if (!pipe && DOWNLOAD.test(text2) && !VERIFY.test(text2)) {
|
|
504
|
+
const m = DOWNLOAD.exec(text2);
|
|
505
|
+
const ln = lineAt(m.index);
|
|
506
|
+
addFinding('nix-script-unverified-download', { file, line: ln, span: node.span, subject: `${attr || 'script'}@${ln - ((node.span && node.span.startLine) || 0)}`, name: attr || 'script', severity: ctx === 'build-time' ? 'low' : 'medium', evidence: { attribute: attr || null, context: ctx } });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function handleAttrset(node, ov) {
|
|
511
|
+
const attrs = attrsOf(node);
|
|
512
|
+
if (attrs.get('__contentAddressed')) {
|
|
513
|
+
const v = unparen(attrs.get('__contentAddressed').node);
|
|
514
|
+
if (v && v.type === 'ident' && v.name === 'true') inventory.contentAddressed.push({ file, line: spanLine(node.span), kind: 'content-addressed-derivation' });
|
|
515
|
+
}
|
|
516
|
+
if (attrs.has('outputHash') && !handledSets.has(node)) {
|
|
517
|
+
const hs = hashState(new Map([['outputHash', attrs.get('outputHash')]]));
|
|
518
|
+
if (hs.state === 'fake') addFinding('nix-fetch-fake-hash', { file, span: hs.span, subject: `outputHash@${spanLine(hs.span)}`, name: 'outputHash', evidence: { hashAttr: 'outputHash', context: 'fixed-output-derivation' } });
|
|
519
|
+
else if (hs.state === 'present') inventory.contentAddressed.push({ file, line: spanLine(node.span), kind: 'fixed-output-derivation' });
|
|
520
|
+
}
|
|
521
|
+
const hd = attrs.get('hardeningDisable');
|
|
522
|
+
if (hd && hd.node && ov) {
|
|
523
|
+
const l = unparen(hd.node);
|
|
524
|
+
const flags = l && l.type === 'list' ? l.items.map(strLiteral).filter((x) => typeof x === 'string' && HARDENING_FLAGS.has(x)) : [];
|
|
525
|
+
if (flags.length) addFinding('nix-overlay-hardening-disabled', { file, span: hd.span, subject: `hardeningDisable@${spanLine(hd.span)}`, name: 'hardeningDisable', evidence: { flags, inOverlay: true }, extra: { flags } });
|
|
526
|
+
}
|
|
527
|
+
const kv = attrs.get('knownVulnerabilities');
|
|
528
|
+
if (kv && kv.node && ov) {
|
|
529
|
+
const l = unparen(kv.node);
|
|
530
|
+
if (l && l.type === 'list' && l.items.length === 0) addFinding('nix-overlay-clears-vuln-marker', { file, span: kv.span, subject: `knownVulnerabilities@${spanLine(kv.span)}`, name: 'knownVulnerabilities', evidence: { inOverlay: true, value: [] } });
|
|
531
|
+
}
|
|
532
|
+
const pip = attrs.get('permittedInsecurePackages');
|
|
533
|
+
const ai = attrs.get('allowInsecure');
|
|
534
|
+
const aiTrue = ai && ai.node && unparen(ai.node).type === 'ident' && unparen(ai.node).name === 'true';
|
|
535
|
+
const pipItems = pip && pip.node && unparen(pip.node).type === 'list' ? unparen(pip.node).items : [];
|
|
536
|
+
if (pipItems.length || aiTrue) {
|
|
537
|
+
const sp = (pipItems.length ? pip : ai).span;
|
|
538
|
+
addFinding('nix-insecure-packages-permitted', { file, span: sp, subject: `${pipItems.length ? 'permittedInsecurePackages' : 'allowInsecure'}@${spanLine(sp)}`, name: pipItems.length ? 'permittedInsecurePackages' : 'allowInsecure', severity: aiTrue ? 'medium' : 'low', evidence: { packages: pipItems.map(strLiteral).filter((x) => typeof x === 'string'), blanket: !!aiTrue } });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// iterative walk carrying the enclosing attribute name and overlay context
|
|
543
|
+
const stack = [{ node: parse.ast, attr: null, ov: fileOverlay }];
|
|
544
|
+
let guard = 0;
|
|
545
|
+
while (stack.length && guard++ < 200_000) {
|
|
546
|
+
const { node, attr, ov } = stack.pop();
|
|
547
|
+
if (!node) continue;
|
|
548
|
+
switch (node.type) {
|
|
549
|
+
case 'app': {
|
|
550
|
+
if (!innerApps.has(node)) {
|
|
551
|
+
let x = unparen(node.fn);
|
|
552
|
+
while (x && x.type === 'app') { innerApps.add(x); x = unparen(x.fn); }
|
|
553
|
+
handleCall(node);
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case 'string': handleString(node, attr); break;
|
|
558
|
+
case 'attrset': handleAttrset(node, ov); break;
|
|
559
|
+
case 'lambda': {
|
|
560
|
+
const body = unparen(node.body);
|
|
561
|
+
if (node.param.kind === 'ident' && body && body.type === 'lambda' && body.param.kind === 'ident'
|
|
562
|
+
&& FINAL_NAMES.has(node.param.name) && PREV_NAMES.has(body.param.name)) {
|
|
563
|
+
stack.push({ node: body.body, attr, ov: true });
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
default: break;
|
|
569
|
+
}
|
|
570
|
+
if (node.type === 'attrset' || node.type === 'let') {
|
|
571
|
+
for (const b of node.bindings) {
|
|
572
|
+
if (b.kind === 'attr') {
|
|
573
|
+
const last = b.path[b.path.length - 1];
|
|
574
|
+
stack.push({ node: b.value, attr: last && last.kind === 'static' ? last.name : null, ov });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (node.type === 'let') stack.push({ node: node.body, attr, ov });
|
|
578
|
+
} else {
|
|
579
|
+
for (const k of childrenOf(node)) stack.push({ node: k, attr, ov });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// flake inputs and nixConfig
|
|
584
|
+
if (ir.flake) {
|
|
585
|
+
const lock = lockFor(file);
|
|
586
|
+
for (const inp of ir.flake.inputs) {
|
|
587
|
+
if (inp.follows) { inventory.flakeInputs.push({ name: inp.name, file, line: spanLine(inp.span), locked: 'follows', follows: inp.follows }); continue; }
|
|
588
|
+
if (inp.dynamic || typeof inp.url !== 'string') {
|
|
589
|
+
inventory.flakeInputs.push({ name: inp.name, file, line: spanLine(inp.span), locked: 'unknown' });
|
|
590
|
+
gaps.push({ kind: 'dynamic-flake-input', detail: `flake input "${inp.name}" has a computed or missing url, so its pin was not judged`, file, line: spanLine(inp.span) });
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const u = inp.url;
|
|
594
|
+
if (/^(?:path:|git\+file:|file:|\.{0,2}\/)/.test(u)) { inventory.flakeInputs.push({ name: inp.name, file, line: spanLine(inp.span), url: u, locked: 'local' }); continue; }
|
|
595
|
+
const urlPinned = /^github:[^/]+\/[^/?#]+\/[0-9a-f]{40}(?:[?#].*)?$/i.test(u) || /[?&]rev=[0-9a-f]{40}/i.test(u) || /[?&]narHash=/.test(u);
|
|
596
|
+
const floatingRef = /^github:[^/]+\/[^/?#]+\/(?![0-9a-f]{40}(?:[?#]|$))[^/?#]+/i.test(u) || /[?&]ref=/.test(u) || /^github:[^/]+\/[^/?#]+$/i.test(u);
|
|
597
|
+
let locked; let by = null; let reason = null;
|
|
598
|
+
let lockNode = null;
|
|
599
|
+
if (lock.present && lock.nodes) {
|
|
600
|
+
const rootNode = lock.nodes[lock.root];
|
|
601
|
+
const key = rootNode && rootNode.inputs ? rootNode.inputs[inp.name] : undefined;
|
|
602
|
+
lockNode = typeof key === 'string' ? lock.nodes[key] : null;
|
|
603
|
+
}
|
|
604
|
+
if (urlPinned) { locked = true; by = 'url'; }
|
|
605
|
+
else if (lockNode && lockNode.locked && (lockNode.locked.rev || lockNode.locked.narHash)) { locked = true; by = 'flake.lock'; }
|
|
606
|
+
else if (lock.present && lock.invalid) { locked = 'unknown'; reason = 'flake-lock-unparseable'; }
|
|
607
|
+
else if (lock.present) { locked = false; reason = 'missing-from-lock'; }
|
|
608
|
+
else { locked = false; reason = 'no-lock-file'; }
|
|
609
|
+
inventory.flakeInputs.push({ name: inp.name, file, line: spanLine(inp.span), url: u, locked, lockedBy: by, floatingRef, reason, lockFreshness: 'not-checked' });
|
|
610
|
+
if (locked === false) {
|
|
611
|
+
addFinding('nix-flake-input-unlocked', { file, span: inp.span, subject: `input:${inp.name}`, name: inp.name, evidenceKind: 'lock', evidence: { input: inp.name, url: u, floatingRef, reason, lockFile: lock.path, lockPresent: lock.present }, extra: reason === 'no-lock-file' ? { uncertainty: [{ kind: 'unresolved-import', detail: 'flake.lock is not among the scanned files' }] } : {} });
|
|
612
|
+
} else if (locked === true) {
|
|
613
|
+
controls.push({ control: 'flake-input-locked', input: inp.name, lockedBy: by, floatingRef, file, line: spanLine(inp.span) });
|
|
614
|
+
}
|
|
615
|
+
if (/^(?:http|git\+http|git):\/\//i.test(u)) {
|
|
616
|
+
addFinding('nix-fetch-insecure-transport', { file, span: inp.span, subject: `input:${inp.name}`, name: inp.name, severity: locked === true ? 'low' : 'high', evidenceKind: 'lock', evidence: { input: inp.name, url: u, locked }, extra: { contentPinned: locked === true } });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const cacheKeys = new Set(['extra-substituters', 'substituters', 'extra-trusted-substituters', 'extra-trusted-public-keys', 'trusted-public-keys']);
|
|
620
|
+
for (const b of ir.bindings) {
|
|
621
|
+
if (b.scope !== 'file' || b.path[0] !== 'nixConfig' || b.path.length !== 2 || !cacheKeys.has(b.path[1])) continue;
|
|
622
|
+
const items = b.value.type === 'list' ? b.value.items.length : 1;
|
|
623
|
+
if (!items) continue;
|
|
624
|
+
addFinding('nix-flake-nixconfig-cache', { file, span: b.span, subject: `nixConfig.${b.path[1]}`, name: b.path[1], evidenceKind: 'config', evidence: { setting: b.path[1], effective: 'only if the user accepts the flake config or accept-flake-config is true' }, trustBoundary: CACHE_TRUST_BOUNDARY });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
for (const file of Object.keys(files).sort()) {
|
|
630
|
+
if (!/\.nix$/.test(file) || isLanguageExcludedPath(file)) continue;
|
|
631
|
+
try { analyzeSource(file, files[file]); } catch (e) {
|
|
632
|
+
gaps.push({ kind: 'analysis-error', detail: `build-trust source analysis failed: ${String((e && e.message) || e)}`, file, line: 1 });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── CONFIG half ──
|
|
637
|
+
const entry = opts.entry || (typeof files['configuration.nix'] === 'string' ? 'configuration.nix' : null);
|
|
638
|
+
let cfg = null;
|
|
639
|
+
if (entry && typeof files[entry] === 'string') {
|
|
640
|
+
try { cfg = resolveNixosConfig({ entry, files, target: opts.target }); } catch (e) {
|
|
641
|
+
gaps.push({ kind: 'config-resolution-failed', detail: String((e && e.message) || e), file: entry, line: 1 });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (cfg) {
|
|
645
|
+
context = { target: cfg.target, release: cfg.target && cfg.target.release ? normalizeRelease(cfg.target.release) : null, releaseSource: cfg.target && cfg.target.releaseSource ? cfg.target.releaseSource : undefined, catalogRevision: cfg.catalog && cfg.catalog.revision ? cfg.catalog.revision : (cfg.catalog || null), scope: 'nix.conf as applied by the NixOS module system' };
|
|
646
|
+
const partial = cfg.completeness !== 'complete';
|
|
647
|
+
const eoRes = cfg.lookup('nix.extraOptions');
|
|
648
|
+
const eoView = view(eoRes);
|
|
649
|
+
const extra = { text: eoView.state === 'known' && typeof eoView.value === 'string' ? { lines: parseExtraOptions(eoView.value), evidence: optionEvidence(eoRes), anchor: optionAnchor(eoRes), definite: !!eoView.definite } : null };
|
|
650
|
+
const pkgRes = cfg.lookup('nix.package');
|
|
651
|
+
const nixPackage = (pkgRes.sources || []).length ? 'set explicitly (the Nix version is not evaluated)' : 'release default (the Nix version follows the NixOS release)';
|
|
652
|
+
const S = (key) => readSetting(cfg, key, extra);
|
|
653
|
+
const anchorOfSetting = (s) => s.anchors[s.anchors.length - 1] || { file: entry, line: 1 };
|
|
654
|
+
const emit = (rule, s, j, { severity, subject, extra: more = {}, trustBoundary } = {}) => addFinding(rule, {
|
|
655
|
+
severity, file: anchorOfSetting(s).file, line: anchorOfSetting(s).line, subject: subject || `nix.settings.${s.key}`, name: `nix.settings.${s.key}`,
|
|
656
|
+
state: j === 'conditional' ? 'conditional' : 'active', uncertain: !s.definite, partial, evidenceKind: 'config', capability: 'iac',
|
|
657
|
+
evidence: { setting: s.key, effectiveValue: s.list ? s.items : s.value, possibleValues: s.state === 'conditional' ? (s.list ? s.possibleItems : s.possible) : undefined, viaExtraOptions: s.viaText, nixPackage, options: s.evidence },
|
|
658
|
+
trustBoundary, extra: more,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// trusted-users
|
|
662
|
+
{
|
|
663
|
+
const s = S('trusted-users');
|
|
664
|
+
const wide = (x) => typeof x === 'string' && x !== 'root';
|
|
665
|
+
const j = holds(s, wide);
|
|
666
|
+
if (j === 'yes' || j === 'conditional') {
|
|
667
|
+
const items = (j === 'yes' ? s.items : s.possibleItems).filter(wide);
|
|
668
|
+
const sev = items.some((x) => x === '*' || x === '@all') ? 'high' : 'medium';
|
|
669
|
+
emit('nix-trusted-users-widened', s, j, { severity: sev, extra: { widenedBy: items } });
|
|
670
|
+
} else if (s.state === 'known') controls.push({ control: 'trusted-users-root-only', option: 'nix.settings.trusted-users', applies: true, value: s.items });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// signature checking and cache transport
|
|
674
|
+
const sigs = S('require-sigs');
|
|
675
|
+
const features = S('experimental-features');
|
|
676
|
+
const caEnabled = holds(features, (x) => x === 'ca-derivations') === 'yes';
|
|
677
|
+
{
|
|
678
|
+
const j = holds(sigs, (x) => x === false);
|
|
679
|
+
if (j === 'yes' || j === 'conditional') {
|
|
680
|
+
emit('nix-require-sigs-disabled', sigs, j, { extra: { affects: 'input-addressed paths', contentAddressedPathsAffected: false, caDerivationsEnabled: caEnabled }, trustBoundary: CACHE_TRUST_BOUNDARY });
|
|
681
|
+
} else if (sigs.state === 'known') controls.push({ control: 'signatures-required', option: 'nix.settings.require-sigs', applies: true, note: caEnabled ? 'content-addressed paths are verified by address; signatures cover input-addressed paths' : undefined });
|
|
682
|
+
}
|
|
683
|
+
{
|
|
684
|
+
const sigsOff = holds(sigs, (x) => x === false) === 'yes';
|
|
685
|
+
for (const key of ['substituters', 'trusted-substituters']) {
|
|
686
|
+
const s = S(key);
|
|
687
|
+
const http = (x) => typeof x === 'string' && /^http:\/\//i.test(x);
|
|
688
|
+
const j = holds(s, http);
|
|
689
|
+
if (j === 'yes' || j === 'conditional') {
|
|
690
|
+
emit('nix-substituter-insecure-transport', s, j, { severity: sigsOff ? 'high' : 'low', extra: { signaturesRequired: !sigsOff, insecureSubstituters: (j === 'yes' ? s.items : s.possibleItems).filter(http) }, trustBoundary: CACHE_TRUST_BOUNDARY });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
{
|
|
695
|
+
const s = S('accept-flake-config');
|
|
696
|
+
const j = holds(s, (x) => x === true);
|
|
697
|
+
if (j === 'yes' || j === 'conditional') emit('nix-accept-flake-config', s, j, { trustBoundary: CACHE_TRUST_BOUNDARY });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// sandbox
|
|
701
|
+
{
|
|
702
|
+
const s = S('sandbox');
|
|
703
|
+
const j = holds(s, (x) => x === false || x === 'relaxed');
|
|
704
|
+
if (j === 'yes' || j === 'conditional') emit('nix-sandbox-disabled', s, j, { severity: s.value === 'relaxed' ? 'medium' : 'high', extra: { mode: s.value === 'relaxed' ? 'relaxed' : 'off' } });
|
|
705
|
+
else if (s.state === 'known' && s.value === true) controls.push({ control: 'sandbox-enabled', option: 'nix.settings.sandbox', applies: true });
|
|
706
|
+
{
|
|
707
|
+
const sp = S('sandbox-paths'); // also reads extra-sandbox-paths
|
|
708
|
+
const bad = (x) => typeof x === 'string' && SENSITIVE_PATHS.some((re) => re.test(x.split('=').pop().replace(/\?$/, '')));
|
|
709
|
+
const jp = holds(sp, bad);
|
|
710
|
+
if (jp === 'yes' || jp === 'conditional') {
|
|
711
|
+
emit('nix-sandbox-sensitive-path', sp, jp, { extra: { paths: (jp === 'yes' ? sp.items : sp.possibleItems).filter(bad) } });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const bu = S('build-users-group');
|
|
715
|
+
const jb = holds(bu, (x) => x === '' || (Array.isArray(x) && x.length === 0));
|
|
716
|
+
if (jb === 'yes' || jb === 'conditional') emit('nix-build-users-group-empty', bu, jb);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// native evaluation and IFD policy
|
|
720
|
+
{
|
|
721
|
+
const s = S('allow-unsafe-native-code-during-evaluation');
|
|
722
|
+
const j = holds(s, (x) => x === true);
|
|
723
|
+
if (j === 'yes' || j === 'conditional') emit('nix-unsafe-native-eval', s, j);
|
|
724
|
+
const pl = S('plugin-files');
|
|
725
|
+
const jp = holds(pl, (x) => typeof x === 'string' && x !== '');
|
|
726
|
+
if (jp === 'yes' || jp === 'conditional') emit('nix-eval-native-plugin', pl, jp);
|
|
727
|
+
const eb = S('extra-builtins-file');
|
|
728
|
+
const je = holds(eb, (x) => typeof x === 'string' && x !== '');
|
|
729
|
+
if (je === 'yes' || je === 'conditional') emit('nix-extra-builtins-file', eb, je);
|
|
730
|
+
const ifd = S('allow-import-from-derivation');
|
|
731
|
+
const ji = holds(ifd, (x) => x === true);
|
|
732
|
+
if (ji === 'yes' || ji === 'conditional') emit('nix-ifd-policy-enabled', ifd, ji);
|
|
733
|
+
else if (ifd.state === 'known' && ifd.value === false) controls.push({ control: 'ifd-disabled', option: 'nix.settings.allow-import-from-derivation', applies: true });
|
|
734
|
+
inventory.ifdPolicy = ifd.state === 'known' ? (ifd.value === false ? 'forbidden' : 'allowed') : 'unspecified';
|
|
735
|
+
if (inventory.ifdPolicy === 'forbidden') {
|
|
736
|
+
for (const f of findings) {
|
|
737
|
+
if (f.rule === 'nix-ifd-boundary') { f.evidence = { ...f.evidence, policyBlocks: true }; f.severity = 'info'; f.description += ' Policy allow-import-from-derivation = false forbids this at evaluation time.'; }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
for (const u of cfg.unresolved || []) {
|
|
742
|
+
if (!['home-manager-function-module'].includes(u.kind)) gaps.push({ kind: u.kind, detail: u.detail, file: u.file || entry, line: 1 });
|
|
743
|
+
}
|
|
744
|
+
for (const t of cfg.truncated || []) gaps.push({ kind: 'truncated', detail: `module evaluation budget "${t.budget}" reached`, file: entry, line: 1 });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
findings.sort((a, b) => a.id.localeCompare(b.id) || a.line - b.line);
|
|
748
|
+
redactUrlsDeep(findings); redactUrlsDeep(inventory); redactUrlsDeep(gaps);
|
|
749
|
+
return {
|
|
750
|
+
kind: 'nix-build-trust-report', version: 1, rulesetVersion: BUILD_TRUST_RULESET_VERSION,
|
|
751
|
+
target: context.target, release: context.release, catalog: context.catalogRevision || null,
|
|
752
|
+
findings, controls, inventory, gaps,
|
|
753
|
+
scope: { executed: false, network: false, note: 'fetch, IFD and custom builders are reported as boundaries; nothing is evaluated, fetched or run', cacheTrust: CACHE_TRUST_BOUNDARY },
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ── language pipeline adapter ──────────────────────────────────────────────
|
|
758
|
+
|
|
759
|
+
export function ensureNixBuildTrustProducer() {
|
|
760
|
+
if (!isRegisteredLanguageProducer(NIX_BUILD_TRUST_PRODUCER)) {
|
|
761
|
+
registerLanguageProducer({ id: NIX_BUILD_TRUST_PRODUCER, language: 'nix', capability: 'sca', evidenceKinds: ['source', 'lock', 'config'], version: '1' });
|
|
762
|
+
}
|
|
763
|
+
return NIX_BUILD_TRUST_PRODUCER;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Adapter for runLanguageAnalysis. The configuration half needs the whole file set, so it is
|
|
768
|
+
* computed once from `opts.files` and each file receives the findings anchored in it. Gaps become
|
|
769
|
+
* `unresolved` outcomes, which scan health reports.
|
|
770
|
+
* @param {{files: Record<string,string>, entry?: string, target?: object}} opts
|
|
771
|
+
*/
|
|
772
|
+
export function createNixBuildTrustAdapter(opts = {}) {
|
|
773
|
+
ensureNixBuildTrustProducer();
|
|
774
|
+
let report = null;
|
|
775
|
+
const get = () => (report = report || analyzeNixBuildTrust(opts));
|
|
776
|
+
const home = () => opts.entry || 'configuration.nix';
|
|
777
|
+
return {
|
|
778
|
+
id: NIX_BUILD_TRUST_PRODUCER,
|
|
779
|
+
language: 'nix',
|
|
780
|
+
hasGrammar: () => loadNixGrammar().available,
|
|
781
|
+
report: get,
|
|
782
|
+
analyze(file) {
|
|
783
|
+
const r = get();
|
|
784
|
+
return {
|
|
785
|
+
findings: r.findings.filter((f) => (f.file || home()) === file),
|
|
786
|
+
unresolved: r.gaps.filter((g) => (g.file || home()) === file).map((g) => ({ line: g.line || 1, reason: `${g.kind}: ${g.detail}` })),
|
|
787
|
+
};
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
}
|