@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,885 @@
|
|
|
1
|
+
// Nix interpolation and embedded-shell taint (NIX-003).
|
|
2
|
+
//
|
|
3
|
+
// A Nix string that becomes a shell script is two languages stacked: Nix decides what text is produced,
|
|
4
|
+
// the shell decides what that text does. This module keeps the boundary explicit:
|
|
5
|
+
//
|
|
6
|
+
// * The Nix string is DECODED into the exact generated script (escapes resolved, indentation stripped),
|
|
7
|
+
// with one placeholder (U+FFFC) per `${...}` interpolation and a per-character map back to the original
|
|
8
|
+
// Nix source offsets. A literal `''${HOME}` is therefore shell text (a shell expansion), never a Nix
|
|
9
|
+
// interpolation, and `${name}` is a Nix interpolation, never shell text.
|
|
10
|
+
// * The generated script is lexed as shell: quoting state at every placeholder (unquoted, "...", '...',
|
|
11
|
+
// heredoc, command substitution, comment), the command it is an argument of and its sink class.
|
|
12
|
+
// * Each interpolated expression is resolved through let bindings, function parameters, selects, imported
|
|
13
|
+
// helper files and small helper lambdas to a set of ORIGINS: configuration, attacker (environment,
|
|
14
|
+
// files and fetched content outside the repository), argument, secret, store path, constant. Origins
|
|
15
|
+
// stay distinct all the way to the finding; configuration is never promoted to attacker.
|
|
16
|
+
// * Protection is judged in context: escapeShellArg(s) neutralises a value only in an UNQUOTED position;
|
|
17
|
+
// inside "..." or '...' it is a wrong-context escape and is reported as such.
|
|
18
|
+
//
|
|
19
|
+
// Static only: nothing here evaluates Nix, runs a shell or touches the network. Scripts in a shell or
|
|
20
|
+
// language that is not modelled are listed as coverage gaps and never reported as analyzed.
|
|
21
|
+
|
|
22
|
+
import { parseNix, makeLocator } from './nix-parser.js';
|
|
23
|
+
import { resolveImportPath } from './nix-ir.js';
|
|
24
|
+
import { isLanguageExcludedPath } from './discovery.js';
|
|
25
|
+
|
|
26
|
+
export const NIX_SCRIPT_VERSION = 'nix-script-taint/1';
|
|
27
|
+
const PH = '';
|
|
28
|
+
const MAX_DEPTH = 24;
|
|
29
|
+
|
|
30
|
+
// ── where shell lives ────────────────────────────────────────────────────────
|
|
31
|
+
const SHELL_ATTRS = new Set(['script', 'preStart', 'postStart', 'preStop', 'postStop', 'reload', 'buildPhase', 'installPhase', 'configurePhase', 'checkPhase', 'unpackPhase', 'patchPhase', 'fixupPhase', 'installCheckPhase', 'distPhase', 'postInstall', 'preInstall', 'postBuild', 'preBuild', 'postPatch', 'prePatch', 'postConfigure', 'preConfigure', 'postUnpack', 'preUnpack', 'postFixup', 'preFixup', 'postCheck', 'preCheck', 'shellHook', 'buildCommand', 'extraCommands', 'postDeviceCommands', 'preDeviceCommands', 'shellInit', 'interactiveShellInit', 'loginShellInit', 'promptInit', 'setup', 'userSetup']);
|
|
32
|
+
const SHELL_BUILDERS = new Map([
|
|
33
|
+
['writeShellScript', { shell: 'bash', arg: 1 }], ['writeShellScriptBin', { shell: 'bash', arg: 1 }], ['writeBash', { shell: 'bash', arg: 1 }], ['writeBashBin', { shell: 'bash', arg: 1 }], ['writeDash', { shell: 'dash', arg: 1 }], ['writeDashBin', { shell: 'dash', arg: 1 }],
|
|
34
|
+
['runCommand', { shell: 'bash', arg: 2 }], ['runCommandLocal', { shell: 'bash', arg: 2 }], ['runCommandNoCC', { shell: 'bash', arg: 2 }], ['runCommandCC', { shell: 'bash', arg: 2 }],
|
|
35
|
+
['writeScript', { shell: 'shebang', arg: 1 }], ['writeScriptBin', { shell: 'shebang', arg: 1 }],
|
|
36
|
+
['writeFish', { shell: 'fish', arg: 1 }], ['writeFishBin', { shell: 'fish', arg: 1 }], ['writePython3', { shell: 'python', arg: 1 }], ['writePython3Bin', { shell: 'python', arg: 1 }], ['writePerl', { shell: 'perl', arg: 1 }], ['writeRuby', { shell: 'ruby', arg: 1 }], ['writeJS', { shell: 'node', arg: 1 }], ['writeNu', { shell: 'nushell', arg: 1 }],
|
|
37
|
+
]);
|
|
38
|
+
const SUPPORTED_SHELLS = new Set(['bash', 'sh', 'dash', 'ash', 'ksh', 'mksh']);
|
|
39
|
+
const PROTECT_ONE = new Set(['escapeShellArg']);
|
|
40
|
+
const PROTECT_MANY = new Set(['escapeShellArgs']);
|
|
41
|
+
const WRONG_ESCAPES = new Set(['escapeRegex', 'escapeXML', 'escapeNixString', 'escapeNixIdentifier', 'escapeURL', 'escapeC', 'escapeSystemdPath', 'escapeSystemdExecArg']);
|
|
42
|
+
const PASS_THROUGH = new Set(['toString', 'toLower', 'toUpper', 'removeSuffix', 'removePrefix', 'trim', 'fileContents', 'optionalString', 'concatStringsSep', 'concatMapStringsSep', 'concatStrings', 'replaceStrings', 'substring', 'head', 'elemAt', 'attrValues', 'attrNames', 'toJSON', 'fromJSON', 'importJSON', 'importTOML', 'fromTOML', 'getAttr', 'catAttrs', 'optional', 'optionals', 'flatten', 'filter', 'sort', 'unique', 'mapAttrsToList', 'listToAttrs', 'genList', 'foldl\'', 'foldl', 'concatLists', 'map', 'mkIf', 'mkDefault', 'mkForce', 'mkOverride', 'mkMerge', 'mkBefore', 'mkAfter']);
|
|
43
|
+
const TRUSTED_PARAMS = new Set(['pkgs', 'lib', 'stdenv', 'self', 'inputs', 'system', 'modulesPath', 'utils', 'nixpkgs', 'flake-utils', 'callPackage', 'fetchurl', 'fetchFromGitHub', 'fetchgit', 'fetchzip', 'writeShellScript', 'writeShellScriptBin', 'runCommand', 'runCommandLocal', 'makeWrapper', 'bash', 'coreutils', 'systemd', 'writeText', 'writeTextFile', 'buildEnv', 'symlinkJoin', 'mkShell', 'mkDerivation', 'buildGoModule', 'rustPlatform', 'darwin']);
|
|
44
|
+
const CONFIG_PARAMS = new Set(['config', 'options', 'osConfig']);
|
|
45
|
+
const RUNTIME_SECRET_DIRS = /^\/(?:run\/(?:secrets|agenix|credentials|keys)|var\/lib\/(?:secrets|credentials)|etc\/secrets)\b/;
|
|
46
|
+
const SECRET_NAME = /(?:pass(?:word|wd|phrase)?|psk|secret|token|api_?key|private_?key|credential|auth_?key|client_?secret|bearer|cookie|session_?key)/i;
|
|
47
|
+
const PATHISH_TAIL = /(?:file|path|dir|directory|package|pkg|enable|port|user|group|name)$/i;
|
|
48
|
+
const FETCHERS = new Set(['fetchurl', 'fetchTarball', 'fetchGit', 'fetchzip', 'fetchFromGitHub', 'fetchgit', 'fetchTree', 'fetchClosure', 'fetchMercurial', 'fetchpatch']);
|
|
49
|
+
const FS_CMDS = new Set(['rm', 'rmdir', 'mv', 'cp', 'chmod', 'chown', 'chgrp', 'mkdir', 'ln', 'touch', 'tee', 'dd', 'install', 'truncate', 'shred', 'unlink', 'rsync', 'tar', 'unzip', 'cat', 'sed', 'find', 'mount', 'umount']);
|
|
50
|
+
const NET_CMDS = new Set(['curl', 'wget', 'nc', 'ncat', 'netcat', 'ssh', 'scp', 'sftp', 'rsync', 'ftp', 'telnet', 'socat', 'git', 'psql', 'mysql', 'redis-cli', 'mongo', 'aws', 'gsutil']);
|
|
51
|
+
const EVAL_CMDS = new Set(['eval', 'source', '.', 'exec', 'xargs', 'sudo', 'su', 'doas', 'nohup', 'env', 'timeout', 'watch', 'time', 'nice', 'ionice', 'setsid', 'runuser']);
|
|
52
|
+
const SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'ash', 'fish']);
|
|
53
|
+
const SKIP_WORDS = new Set(['!', 'if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'for', 'case', 'esac', 'in', '{', '}', 'function', 'select', 'coproc']);
|
|
54
|
+
|
|
55
|
+
const baseName = (s) => String(s || '').replace(/^.*\//, '');
|
|
56
|
+
const spanOf = (n) => (n && n.span ? n.span : null);
|
|
57
|
+
|
|
58
|
+
// ── files ────────────────────────────────────────────────────────────────────
|
|
59
|
+
class Workspace {
|
|
60
|
+
constructor(files) {
|
|
61
|
+
this.files = files; this.parsed = new Map();
|
|
62
|
+
}
|
|
63
|
+
parse(file) {
|
|
64
|
+
if (this.parsed.has(file)) return this.parsed.get(file);
|
|
65
|
+
const text = this.files[file];
|
|
66
|
+
let rec = null;
|
|
67
|
+
if (typeof text === 'string') {
|
|
68
|
+
const p = parseNix(text, { file });
|
|
69
|
+
rec = { file, text, parse: p, ast: p.ast, loc: makeLocator(text) };
|
|
70
|
+
}
|
|
71
|
+
this.parsed.set(file, rec);
|
|
72
|
+
return rec;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── lexical environments and value resolution ────────────────────────────────
|
|
77
|
+
const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
|
|
78
|
+
const mkEnv = (parent, vars, withs, file) => ({ parent, vars, withs: withs || [], file });
|
|
79
|
+
const lookup = (env, name) => { for (let e = env; e; e = e.parent) if (e.vars.has(name)) return { b: e.vars.get(name), env: e }; return null; };
|
|
80
|
+
const withsOf = (env) => { const out = []; for (let e = env; e; e = e.parent) out.push(...e.withs); return out; };
|
|
81
|
+
const segName = (s) => (s && s.kind === 'static' ? s.name : null);
|
|
82
|
+
const attrKey = (path) => path.map(segName);
|
|
83
|
+
|
|
84
|
+
function bindingsEnv(node, env, file) {
|
|
85
|
+
const vars = new Map();
|
|
86
|
+
for (const b of node.bindings || []) {
|
|
87
|
+
if (b.kind === 'attr') {
|
|
88
|
+
const k = attrKey(b.path);
|
|
89
|
+
if (k.length && k.every((x) => x !== null)) {
|
|
90
|
+
// nested attr paths: a.b.c = v -> register a as a synthetic attrset by path
|
|
91
|
+
if (k.length === 1) vars.set(k[0], { kind: 'bind', value: b.value, span: b.span, file });
|
|
92
|
+
else {
|
|
93
|
+
const head = k[0];
|
|
94
|
+
const cur = vars.get(head);
|
|
95
|
+
const entry = cur && cur.kind === 'pathset' ? cur : { kind: 'pathset', paths: [], span: b.span, file };
|
|
96
|
+
entry.paths.push({ path: k.slice(1), value: b.value, span: b.span });
|
|
97
|
+
vars.set(head, entry);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else if (b.kind === 'inherit') {
|
|
101
|
+
for (const nm of b.names || []) {
|
|
102
|
+
const name = segName(nm);
|
|
103
|
+
if (name) vars.set(name, { kind: 'inherit', from: b.from, span: nm.span || b.span, file, name });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return vars;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Look up a static attribute path in an attrset node; returns {value, span} or null. */
|
|
111
|
+
function attrIn(node, path) {
|
|
112
|
+
if (!node || node.type !== 'attrset') return null;
|
|
113
|
+
for (const b of node.bindings || []) {
|
|
114
|
+
if (b.kind !== 'attr') continue;
|
|
115
|
+
const k = attrKey(b.path);
|
|
116
|
+
if (k.some((x) => x === null)) continue;
|
|
117
|
+
if (k.length <= path.length && k.every((x, i) => x === path[i])) {
|
|
118
|
+
const rest = path.slice(k.length);
|
|
119
|
+
if (!rest.length) return { value: b.value, span: b.span };
|
|
120
|
+
const inner = attrIn(unparen(b.value), rest);
|
|
121
|
+
if (inner) return inner;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class Resolver {
|
|
128
|
+
constructor(ws) { this.ws = ws; this.cycle = new Set(); }
|
|
129
|
+
|
|
130
|
+
/** File top-level expression with module/function formals bound by name. */
|
|
131
|
+
fileTop(file) {
|
|
132
|
+
const rec = this.ws.parse(file);
|
|
133
|
+
if (!rec || !rec.ast) return null;
|
|
134
|
+
let node = unparen(rec.ast);
|
|
135
|
+
let env = mkEnv(null, new Map(), [], file);
|
|
136
|
+
while (node && node.type === 'lambda') {
|
|
137
|
+
const vars = new Map();
|
|
138
|
+
if (node.param.kind === 'pattern') for (const f of node.param.formals) vars.set(f.name, { kind: 'param', name: f.name, span: f.span, file, def: f.default });
|
|
139
|
+
else if (node.param.name) vars.set(node.param.name, { kind: 'param', name: node.param.name, span: node.span, file });
|
|
140
|
+
if (node.param.atName) vars.set(node.param.atName, { kind: 'param', name: node.param.atName, span: node.span, file });
|
|
141
|
+
env = mkEnv(env, vars, [], file);
|
|
142
|
+
node = unparen(node.body);
|
|
143
|
+
}
|
|
144
|
+
return { node, env, file, rec };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Follow identifiers/selects/imports to a node. Returns {node, env, file, hops} (node may be non-static). */
|
|
148
|
+
deref(expr, env, hops = [], depth = 0) {
|
|
149
|
+
const n = unparen(expr);
|
|
150
|
+
if (!n || depth > MAX_DEPTH) return { node: n, env, hops, partial: true };
|
|
151
|
+
if (n.type === 'ident') {
|
|
152
|
+
const f = lookup(env, n.name);
|
|
153
|
+
if (!f) return { node: n, env, hops, free: true };
|
|
154
|
+
const b = f.b;
|
|
155
|
+
if (b.kind === 'bind') {
|
|
156
|
+
const key = `${b.file}:${b.span && b.span.startOffset}`;
|
|
157
|
+
if (this.cycle.has(key)) return { node: n, env, hops, partial: true };
|
|
158
|
+
this.cycle.add(key);
|
|
159
|
+
try { return this.deref(b.value, b.argEnv || f.env, [...hops, { file: b.file, span: b.span, label: `let ${n.name}` }], depth + 1); } finally { this.cycle.delete(key); }
|
|
160
|
+
}
|
|
161
|
+
if (b.kind === 'inherit') {
|
|
162
|
+
if (b.from) { const base = this.deref(b.from, f.env, hops, depth + 1); return this.selectFrom(base, [b.name], hops, depth + 1); }
|
|
163
|
+
const outer = f.env.parent ? lookup(f.env.parent, b.name) : null;
|
|
164
|
+
if (outer) return this.deref({ type: 'ident', name: b.name, span: b.span }, f.env.parent, [...hops, { file: b.file, span: b.span, label: `inherit ${b.name}` }], depth + 1);
|
|
165
|
+
return { node: n, env, hops, free: true };
|
|
166
|
+
}
|
|
167
|
+
if (b.kind === 'pathset') return { node: { type: 'attrset', bindings: b.paths.map((p) => ({ kind: 'attr', path: p.path.map((name) => ({ kind: 'static', name })), value: p.value, span: p.span })), span: b.span }, env: f.env, hops, file: b.file };
|
|
168
|
+
if (b.kind === 'param') return { node: n, env, hops: [...hops, { file: b.file, span: b.span, label: `parameter ${b.name}` }], param: b };
|
|
169
|
+
return { node: n, env, hops };
|
|
170
|
+
}
|
|
171
|
+
if (n.type === 'select') {
|
|
172
|
+
const base = this.deref(n.base, env, hops, depth + 1);
|
|
173
|
+
const path = n.attrpath.map(segName);
|
|
174
|
+
if (path.some((x) => x === null)) return { node: n, env, hops, partial: true };
|
|
175
|
+
return this.selectFrom(base, path, hops, depth + 1, n);
|
|
176
|
+
}
|
|
177
|
+
if (n.type === 'app') {
|
|
178
|
+
const fn = unparen(n.fn);
|
|
179
|
+
if (fn && fn.type === 'ident' && fn.name === 'import') {
|
|
180
|
+
const arg = unparen(n.arg);
|
|
181
|
+
const lit = arg && (arg.type === 'path' || arg.type === 'string') && !arg.interpolated ? arg.literal : null;
|
|
182
|
+
const target = lit ? resolveImportPath(env.file, /\.nix$/.test(lit) || /\/$/.test(lit) ? lit : `${lit}`) : null;
|
|
183
|
+
const cand = target ? [target, `${target.replace(/\/$/, '')}/default.nix`].find((c) => typeof this.ws.files[c] === 'string') : null;
|
|
184
|
+
if (cand) { const top = this.fileTop(cand); if (top) return { ...top, hops: [...hops, { file: cand, span: spanOf(top.node), label: `import ${lit}` }], imported: cand }; }
|
|
185
|
+
return { node: n, env, hops, unresolvedImport: lit || true };
|
|
186
|
+
}
|
|
187
|
+
// `import ./f.nix { ... }` : apply
|
|
188
|
+
const inner = fn && fn.type === 'app' ? this.deref(fn, env, hops, depth + 1) : null;
|
|
189
|
+
if (inner && inner.imported && inner.node && inner.node.type !== 'lambda') return inner;
|
|
190
|
+
}
|
|
191
|
+
return { node: n, env, hops, file: env && env.file };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
selectFrom(base, path, hops, depth, orig) {
|
|
195
|
+
const node = unparen(base.node);
|
|
196
|
+
if (node && node.type === 'attrset') {
|
|
197
|
+
const hit = attrIn(node, path);
|
|
198
|
+
if (hit) return this.deref(hit.value, base.env, [...base.hops, { file: base.file || (base.env && base.env.file), span: hit.span, label: path.join('.') }], depth + 1);
|
|
199
|
+
}
|
|
200
|
+
return { node: orig || node, env: base.env, hops: base.hops, selected: path, base, partial: true };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── origins ──────────────────────────────────────────────────────────────────
|
|
205
|
+
const SAFE_KINDS = new Set(['constant', 'store']);
|
|
206
|
+
const mkOrigin = (kind, detail, chain) => ({ kind, detail, chain: chain || [] });
|
|
207
|
+
const dedupe = (list) => { const seen = new Set(); const out = []; for (const o of list) { const k = `${o.kind}|${o.detail}`; if (!seen.has(k)) { seen.add(k); out.push(o); } } return out; };
|
|
208
|
+
const hop = (file, span, label, kind = 'nix') => (span ? { file, line: span.startLine, column: span.startColumn, endLine: span.endLine, endColumn: span.endColumn, label, kind } : null);
|
|
209
|
+
|
|
210
|
+
class Evaluator {
|
|
211
|
+
constructor(res) { this.res = res; this.seen = new Set(); }
|
|
212
|
+
|
|
213
|
+
combine(parts) {
|
|
214
|
+
const origins = dedupe(parts.flatMap((p) => p.origins));
|
|
215
|
+
const risky = parts.filter((p) => p.origins.some((o) => !SAFE_KINDS.has(o.kind)));
|
|
216
|
+
const protection = risky.length && risky.every((p) => p.protection) ? risky[0].protection : null;
|
|
217
|
+
const wrong = parts.map((p) => p.wrongEscape).find(Boolean) || null;
|
|
218
|
+
return { origins, protection, wrongEscape: wrong };
|
|
219
|
+
}
|
|
220
|
+
constant(detail = 'literal') { return { origins: [mkOrigin('constant', detail)], protection: null }; }
|
|
221
|
+
unknown(detail) { return { origins: [mkOrigin('unknown', detail)], protection: null }; }
|
|
222
|
+
|
|
223
|
+
paramOrigin(name, b, withHops) {
|
|
224
|
+
const hops = withHops || [];
|
|
225
|
+
if (CONFIG_PARAMS.has(name)) return { origins: [mkOrigin('configuration', `module parameter ${name}`, hops)], protection: null };
|
|
226
|
+
if (TRUSTED_PARAMS.has(name)) return { origins: [mkOrigin('store', `trusted parameter ${name}`, hops)], protection: null };
|
|
227
|
+
return { origins: [mkOrigin('argument', `function argument "${name}"`, hops)], protection: null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
eval(expr, env, depth = 0) {
|
|
231
|
+
const n = unparen(expr);
|
|
232
|
+
if (!n || depth > MAX_DEPTH) return this.unknown('depth limit');
|
|
233
|
+
switch (n.type) {
|
|
234
|
+
case 'string': {
|
|
235
|
+
if (!n.interpolated) return this.constant('string literal');
|
|
236
|
+
const parts = [this.constant('string text')];
|
|
237
|
+
for (const p of n.parts) if (p.kind === 'interp') parts.push(this.eval(p.expr, env, depth + 1));
|
|
238
|
+
return this.combine(parts);
|
|
239
|
+
}
|
|
240
|
+
case 'path': return n.interpolated ? this.unknown('interpolated path') : this.constant('path literal');
|
|
241
|
+
case 'ident': return this.evalIdent(n, env, depth);
|
|
242
|
+
case 'select': return this.evalSelect(n, env, depth);
|
|
243
|
+
case 'app': return this.evalApp(n, env, depth);
|
|
244
|
+
case 'binop': return this.combine([this.eval(n.left, env, depth + 1), this.eval(n.right, env, depth + 1)]);
|
|
245
|
+
case 'if': return this.combine([this.eval(n.then, env, depth + 1), this.eval(n.else, env, depth + 1)]);
|
|
246
|
+
case 'list': return n.items.length ? this.combine(n.items.map((i) => this.eval(i, env, depth + 1))) : this.constant('empty list');
|
|
247
|
+
case 'attrset': return this.combine((n.bindings || []).filter((b) => b.kind === 'attr' && b.value).map((b) => this.eval(b.value, env, depth + 1)).concat([this.constant('attrset')]));
|
|
248
|
+
case 'let': { const e2 = mkEnv(env, bindingsEnv(n, env, env.file), [], env.file); return this.eval(n.body, e2, depth + 1); }
|
|
249
|
+
case 'with': { const w = this.withName(n.env); const e2 = mkEnv(env, new Map(), [w], env.file); return this.eval(n.body, e2, depth + 1); }
|
|
250
|
+
case 'assert': return this.eval(n.body, env, depth + 1);
|
|
251
|
+
case 'lambda': return this.constant('function');
|
|
252
|
+
case 'unop': case 'hasattr': return this.constant('boolean');
|
|
253
|
+
default: return this.constant(n.type);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
withName(e) { const n = unparen(e); if (n && n.type === 'ident') return n.name; if (n && n.type === 'select') { const b = unparen(n.base); return b && b.type === 'ident' ? b.name : null; } return null; }
|
|
257
|
+
|
|
258
|
+
evalIdent(n, env, depth) {
|
|
259
|
+
const f = lookup(env, n.name);
|
|
260
|
+
if (!f) {
|
|
261
|
+
if (n.name === 'null' || n.name === 'true' || n.name === 'false') return this.constant(n.name);
|
|
262
|
+
if (['builtins', 'abort', 'throw', 'toString', 'import', 'derivation', 'map'].includes(n.name)) return this.constant(`builtin ${n.name}`);
|
|
263
|
+
for (const w of withsOf(env)) { if (w === 'pkgs' || w === 'lib' || w === 'builtins') return { origins: [mkOrigin('store', `with ${w}`)], protection: null }; if (w && CONFIG_PARAMS.has(w)) return { origins: [mkOrigin('configuration', `with ${w}`)], protection: null }; }
|
|
264
|
+
return this.unknown(`unresolved name "${n.name}"`);
|
|
265
|
+
}
|
|
266
|
+
const b = f.b;
|
|
267
|
+
const here = hop(b.file, b.span, `${n.name}`);
|
|
268
|
+
if (b.kind === 'param') {
|
|
269
|
+
if (b.def) return this.combine([this.paramOrigin(n.name, b, here ? [here] : []), this.eval(b.def, f.env, depth + 1)]);
|
|
270
|
+
return this.paramOrigin(n.name, b, here ? [here] : []);
|
|
271
|
+
}
|
|
272
|
+
const d = this.res.deref(n, env, []);
|
|
273
|
+
if (d.param) return this.paramOrigin(d.param.name, d.param, d.hops.map((h) => hop(h.file, h.span, h.label)).filter(Boolean));
|
|
274
|
+
if (d.node && d.node !== n) {
|
|
275
|
+
const key = `${b.file}:${b.span && b.span.startOffset}:${n.name}`;
|
|
276
|
+
if (this.seen.has(key)) return this.unknown(`recursive definition of ${n.name}`);
|
|
277
|
+
this.seen.add(key);
|
|
278
|
+
try { return this.withChain(this.eval(d.node, d.env, depth + 1), d.hops); } finally { this.seen.delete(key); }
|
|
279
|
+
}
|
|
280
|
+
return this.unknown(`could not resolve ${n.name}`);
|
|
281
|
+
}
|
|
282
|
+
withChain(r, hops) {
|
|
283
|
+
const hs = (hops || []).map((h) => hop(h.file, h.span, h.label)).filter(Boolean);
|
|
284
|
+
if (!hs.length) return r;
|
|
285
|
+
// data-flow order: the definition (nearest the source) first, the use site last
|
|
286
|
+
return { ...r, origins: r.origins.map((o) => ({ ...o, chain: [...o.chain, ...[...hs].reverse()] })) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
evalSelect(n, env, depth) {
|
|
290
|
+
const path = n.attrpath.map(segName);
|
|
291
|
+
const root = unparen(n.base);
|
|
292
|
+
if (root && root.type === 'ident') {
|
|
293
|
+
const f = lookup(env, root.name);
|
|
294
|
+
const isParam = f && f.b.kind === 'param';
|
|
295
|
+
if (isParam && CONFIG_PARAMS.has(root.name) && path.every((x) => x !== null)) {
|
|
296
|
+
const full = `${root.name}.${path.join('.')}`;
|
|
297
|
+
const here = hop(f.b.file, spanOf(n), full);
|
|
298
|
+
const last = path[path.length - 1];
|
|
299
|
+
if (SECRET_NAME.test(path.join('.')) && !PATHISH_TAIL.test(last)) return { origins: [mkOrigin('secret', `option ${full}`, here ? [here] : [])], protection: null };
|
|
300
|
+
return { origins: [mkOrigin('configuration', `option ${full}`, here ? [here] : [])], protection: null };
|
|
301
|
+
}
|
|
302
|
+
if ((root.name === 'pkgs' || root.name === 'lib' || root.name === 'builtins') && (!f || isParam)) return { origins: [mkOrigin('store', `${root.name}.${path.filter(Boolean).join('.')}`)], protection: null };
|
|
303
|
+
}
|
|
304
|
+
const d = this.res.deref(n, env, []);
|
|
305
|
+
if (d.param) return this.paramOrigin(d.param.name, d.param, d.hops.map((h) => hop(h.file, h.span, h.label)).filter(Boolean));
|
|
306
|
+
if (d.node && d.node !== n) return this.withChain(this.eval(d.node, d.env, depth + 1), d.hops);
|
|
307
|
+
// select on a value we cannot see through: inherit the base's origins
|
|
308
|
+
const base = this.eval(n.base, env, depth + 1);
|
|
309
|
+
const tail = path.filter(Boolean);
|
|
310
|
+
const d2 = base.origins.some((o) => o.kind === 'configuration')
|
|
311
|
+
? {
|
|
312
|
+
origins: base.origins.map((o) => {
|
|
313
|
+
if (o.kind !== 'configuration') return o;
|
|
314
|
+
const detail = `${o.detail}.${tail.join('.')}`;
|
|
315
|
+
const last = tail[tail.length - 1] || '';
|
|
316
|
+
const secret = SECRET_NAME.test(tail.join('.')) && !PATHISH_TAIL.test(last);
|
|
317
|
+
return { ...o, kind: secret ? 'secret' : 'configuration', detail };
|
|
318
|
+
}),
|
|
319
|
+
protection: null,
|
|
320
|
+
} : base;
|
|
321
|
+
return d2;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
calleeOf(fn) {
|
|
325
|
+
const n = unparen(fn);
|
|
326
|
+
if (!n) return { name: null, full: null };
|
|
327
|
+
if (n.type === 'ident') return { name: n.name, full: n.name, root: n.name };
|
|
328
|
+
if (n.type === 'select') {
|
|
329
|
+
const segs = n.attrpath.map(segName);
|
|
330
|
+
const root = unparen(n.base);
|
|
331
|
+
const rn = root && root.type === 'ident' ? root.name : null;
|
|
332
|
+
return { name: segs[segs.length - 1], full: `${rn || '?'}.${segs.join('.')}`, root: rn };
|
|
333
|
+
}
|
|
334
|
+
return { name: null, full: null };
|
|
335
|
+
}
|
|
336
|
+
flatten(n) {
|
|
337
|
+
const args = []; let fn = unparen(n);
|
|
338
|
+
while (fn && fn.type === 'app') { args.unshift(fn.arg); fn = unparen(fn.fn); }
|
|
339
|
+
return { fn, args };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
evalApp(n, env, depth) {
|
|
343
|
+
const { fn, args } = this.flatten(n);
|
|
344
|
+
const c = this.calleeOf(fn);
|
|
345
|
+
const name = c.name;
|
|
346
|
+
const argv = (i) => (args[i] !== undefined ? this.eval(args[i], env, depth + 1) : this.constant('missing'));
|
|
347
|
+
if (name && PROTECT_ONE.has(name)) { const r = argv(0); return { ...r, protection: 'escapeShellArg', protectionName: name }; }
|
|
348
|
+
if (name && PROTECT_MANY.has(name)) { const r = argv(0); return { ...r, protection: 'escapeShellArgs', protectionName: name }; }
|
|
349
|
+
if (name && WRONG_ESCAPES.has(name)) { const r = argv(0); return { ...r, wrongEscape: name }; }
|
|
350
|
+
if (c.full === 'builtins.getEnv' || name === 'getEnv') return { origins: [mkOrigin('attacker', 'environment variable read at evaluation time (builtins.getEnv)')], protection: null };
|
|
351
|
+
if (name === 'readFile' || name === 'fileContents' || name === 'readFileType') return this.evalReadFile(args[0], env, depth);
|
|
352
|
+
if (c.full && /^(?:builtins\.)?(?:fetch\w+)$/.test(c.full) || (name && FETCHERS.has(name))) return { origins: [mkOrigin('store', `fetched source (${name})`)], protection: null };
|
|
353
|
+
if (name === 'map' || name === 'concatMap' || name === 'mapAttrsToList' || name === 'concatMapStringsSep') {
|
|
354
|
+
const fIdx = name === 'concatMapStringsSep' ? 1 : 0;
|
|
355
|
+
const f = args[fIdx] ? this.calleeOf(args[fIdx]) : { name: null };
|
|
356
|
+
const rest = args.map((a, i) => (i === fIdx ? null : this.eval(a, env, depth + 1))).filter(Boolean);
|
|
357
|
+
const r = rest.length ? this.combine(rest) : this.constant('empty');
|
|
358
|
+
if (f.name && (PROTECT_ONE.has(f.name) || PROTECT_MANY.has(f.name))) return { ...r, protection: 'escapeShellArgs' };
|
|
359
|
+
return r;
|
|
360
|
+
}
|
|
361
|
+
if (name === 'concatStringsSep' || name === 'concatStrings') {
|
|
362
|
+
const parts = args.map((a) => this.eval(a, env, depth + 1));
|
|
363
|
+
const sep = parts[0]; const rest = parts.slice(1);
|
|
364
|
+
const r = rest.length ? this.combine(rest) : this.constant('empty');
|
|
365
|
+
return { ...r, origins: dedupe([...(sep ? sep.origins : []), ...r.origins]) };
|
|
366
|
+
}
|
|
367
|
+
if (name === 'optionalString' || name === 'optional' || name === 'optionals') return args[1] ? this.eval(args[1], env, depth + 1) : this.constant('empty');
|
|
368
|
+
if (name === 'getExe' || name === 'getBin' || name === 'getLib' || name === 'getDev' || name === 'getExe\'') return { origins: [mkOrigin('store', `${name} (store path)`)], protection: null };
|
|
369
|
+
if (name === 'writeText' || name === 'writeTextFile' || name === 'writeShellScript' || name === 'writeShellScriptBin' || name === 'writeScript' || name === 'writeScriptBin' || name === 'runCommand' || name === 'mkDerivation') return { origins: [mkOrigin('store', `${name} output (store path)`)], protection: null };
|
|
370
|
+
// user helper: lambda or an identifier bound to one (possibly imported from another file)
|
|
371
|
+
const helper = this.helperLambda(fn, env);
|
|
372
|
+
if (helper) return this.applyHelper(helper, args, env, depth);
|
|
373
|
+
if (name && PASS_THROUGH.has(name)) return args.length ? this.combine(args.map((a) => this.eval(a, env, depth + 1))) : this.constant('empty');
|
|
374
|
+
// unknown function: its result depends on its arguments; the function itself is not evidence
|
|
375
|
+
const parts = args.map((a) => this.eval(a, env, depth + 1));
|
|
376
|
+
const r = parts.length ? this.combine(parts) : this.constant('call');
|
|
377
|
+
if (name && !r.origins.every((o) => SAFE_KINDS.has(o.kind))) r.origins = dedupe([...r.origins, mkOrigin('unknown', `result of ${c.full || name} (not modelled)`)]);
|
|
378
|
+
return { ...r, protection: null };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
helperLambda(fn, env) {
|
|
382
|
+
const d = this.res.deref(fn, env, []);
|
|
383
|
+
const node = unparen(d.node);
|
|
384
|
+
if (node && node.type === 'lambda' && !d.partial) return { lambda: node, env: d.env, hops: d.hops };
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
applyHelper(h, args, env, depth) {
|
|
388
|
+
let node = h.lambda; let e = h.env; let i = 0;
|
|
389
|
+
while (node && node.type === 'lambda' && i < args.length) {
|
|
390
|
+
const vars = new Map();
|
|
391
|
+
if (node.param.kind === 'pattern') {
|
|
392
|
+
const a = unparen(args[i]);
|
|
393
|
+
for (const f of node.param.formals) {
|
|
394
|
+
const hit = a && a.type === 'attrset' ? attrIn(a, [f.name]) : null;
|
|
395
|
+
vars.set(f.name, hit ? { kind: 'bind', value: hit.value, span: hit.span, file: env.file, argEnv: env } : { kind: 'param', name: f.name, span: f.span, file: e.file, def: f.default });
|
|
396
|
+
}
|
|
397
|
+
} else if (node.param.name) vars.set(node.param.name, { kind: 'bind', value: args[i], span: spanOf(args[i]), file: env.file, argEnv: env });
|
|
398
|
+
e = mkEnv(e, vars, [], e.file);
|
|
399
|
+
node = unparen(node.body); i++;
|
|
400
|
+
}
|
|
401
|
+
if (!node) return this.unknown('helper body');
|
|
402
|
+
if (node.type === 'lambda') return this.constant('partially applied function');
|
|
403
|
+
// arguments are evaluated in the CALLER's scope: wrap bind values so idents resolve there
|
|
404
|
+
const r = this.eval(node, e, depth + 1);
|
|
405
|
+
return this.withChain(r, h.hops);
|
|
406
|
+
}
|
|
407
|
+
/** A path that only exists decrypted at RUNTIME (sops-nix / agenix / systemd credentials / conventional secret dirs). */
|
|
408
|
+
runtimeSecretPath(arg, env) {
|
|
409
|
+
const d = this.res.deref(arg, env, []);
|
|
410
|
+
const n = unparen(d.node);
|
|
411
|
+
const lit = n && (n.type === 'string' || n.type === 'path') && !n.interpolated ? n.literal : null;
|
|
412
|
+
if (lit && RUNTIME_SECRET_DIRS.test(lit)) return lit;
|
|
413
|
+
const target = d.node === n && n && n.type === 'select' ? n : null;
|
|
414
|
+
const sel = n && n.type === 'select' ? n : unparen(arg);
|
|
415
|
+
if (sel && sel.type === 'select') {
|
|
416
|
+
const base = unparen(sel.base); const segs = sel.attrpath.map(segName);
|
|
417
|
+
const root = base && base.type === 'ident' ? base.name : null;
|
|
418
|
+
if (root && CONFIG_PARAMS.has(root) && segs.every((x) => x !== null) && /^(sops|age|agenix)$/.test(segs[0]) && segs.includes('secrets') && segs[segs.length - 1] === 'path') return `config.${segs.join('.')}`;
|
|
419
|
+
}
|
|
420
|
+
void target;
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
evalReadFile(arg, env, depth) {
|
|
425
|
+
if (!arg) return this.unknown('readFile');
|
|
426
|
+
const d = this.res.deref(arg, env, []);
|
|
427
|
+
const node = unparen(d.node);
|
|
428
|
+
const ao = this.eval(arg, env, depth + 1);
|
|
429
|
+
const rp = this.runtimeSecretPath(arg, env);
|
|
430
|
+
if (rp) return { origins: [mkOrigin('secret', `decrypted secret copied at evaluation time (${rp})`, [hop(env.file, spanOf(unparen(arg)), 'readFile of a runtime secret path')].filter(Boolean))], protection: null, decryptCopy: true };
|
|
431
|
+
if (ao.origins.some((o) => o.kind === 'secret')) return { origins: [mkOrigin('secret', 'file content read at evaluation time (path derives from a secret option)')], protection: null };
|
|
432
|
+
if (node && node.type === 'path' && !node.interpolated && !String(node.literal).startsWith('/')) return { origins: [mkOrigin('configuration', `repository file ${node.literal}`, (d.hops || []).map((h) => hop(h.file, h.span, h.label)).filter(Boolean))], protection: null };
|
|
433
|
+
if (node && node.type === 'app') { const c = this.calleeOf(this.flatten(node).fn); if (c.name && FETCHERS.has(c.name)) return { origins: [mkOrigin('attacker', `content fetched from the network (${c.name}) and read at evaluation time`)], protection: null }; }
|
|
434
|
+
return { origins: [mkOrigin('attacker', 'file content read at evaluation time from a path outside the repository')], protection: null };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── string decoding: generated script + source map ───────────────────────────
|
|
439
|
+
function decodeString(rec, node) {
|
|
440
|
+
const src = rec.text;
|
|
441
|
+
const indented = !!node.indented;
|
|
442
|
+
const d = indented ? 2 : 1;
|
|
443
|
+
const chars = []; // {ch, src, ph?}
|
|
444
|
+
const interps = [];
|
|
445
|
+
const parts = node.parts;
|
|
446
|
+
// raw boundaries of text parts
|
|
447
|
+
let cursor = node.start + d;
|
|
448
|
+
const raws = [];
|
|
449
|
+
for (let i = 0; i < parts.length; i++) {
|
|
450
|
+
const p = parts[i];
|
|
451
|
+
if (p.kind === 'interp') {
|
|
452
|
+
const open = src.lastIndexOf('${', p.expr.start);
|
|
453
|
+
let close = src.indexOf('}', p.expr.end);
|
|
454
|
+
if (close < 0) close = p.expr.end;
|
|
455
|
+
raws.push({ part: p, rawStart: open < 0 ? p.expr.start : open, rawEnd: close + 1 });
|
|
456
|
+
} else raws.push({ part: p, rawStart: null, rawEnd: null });
|
|
457
|
+
}
|
|
458
|
+
for (let i = 0; i < raws.length; i++) {
|
|
459
|
+
const r = raws[i];
|
|
460
|
+
if (r.part.kind === 'interp') { cursor = r.rawEnd; continue; }
|
|
461
|
+
const next = raws.slice(i + 1).find((x) => x.part.kind === 'interp');
|
|
462
|
+
const end = next ? next.rawStart : node.end - d;
|
|
463
|
+
r.rawStart = cursor; r.rawEnd = end;
|
|
464
|
+
}
|
|
465
|
+
let approximate = false;
|
|
466
|
+
// Pre-strip decoded characters with source offsets (escapes resolved).
|
|
467
|
+
const pre = [];
|
|
468
|
+
for (const r of raws) {
|
|
469
|
+
if (r.part.kind === 'interp') {
|
|
470
|
+
const idx = interps.length;
|
|
471
|
+
interps.push({ expr: r.part.expr, rawStart: r.rawStart, rawEnd: r.rawEnd, span: r.part.expr.span || rec.loc.span(r.part.expr.start, r.part.expr.end), index: idx });
|
|
472
|
+
pre.push({ ch: PH, src: r.rawStart, srcEnd: r.rawEnd, ph: idx });
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const raw = src.slice(r.rawStart, r.rawEnd);
|
|
476
|
+
for (let i = 0; i < raw.length;) {
|
|
477
|
+
const c = raw[i];
|
|
478
|
+
if (indented) {
|
|
479
|
+
if (c === "'" && raw[i + 1] === "'" && raw[i + 2] === '$') { pre.push({ ch: '$', src: r.rawStart + i, srcEnd: r.rawStart + i + 3 }); i += 3; continue; }
|
|
480
|
+
if (c === "'" && raw[i + 1] === "'" && raw[i + 2] === "'") { pre.push({ ch: "'", src: r.rawStart + i, srcEnd: r.rawStart + i + 3 }, { ch: "'", src: r.rawStart + i, srcEnd: r.rawStart + i + 3 }); i += 3; continue; }
|
|
481
|
+
if (c === "'" && raw[i + 1] === "'" && raw[i + 2] === '\\') { const e = raw[i + 3]; pre.push({ ch: e === 'n' ? '\n' : e === 'r' ? '\r' : e === 't' ? '\t' : e, src: r.rawStart + i, srcEnd: r.rawStart + i + 4 }); i += 4; continue; }
|
|
482
|
+
} else if (c === '\\' && i + 1 < raw.length) {
|
|
483
|
+
const e = raw[i + 1]; pre.push({ ch: e === 'n' ? '\n' : e === 'r' ? '\r' : e === 't' ? '\t' : e, src: r.rawStart + i, srcEnd: r.rawStart + i + 2 }); i += 2; continue;
|
|
484
|
+
}
|
|
485
|
+
pre.push({ ch: c, src: r.rawStart + i, srcEnd: r.rawStart + i + 1 }); i++;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// The parser's text parts are the FINAL generated text (indentation already stripped). Align them with the
|
|
489
|
+
// pre-strip characters: a character is deleted only if it is indentation whitespace.
|
|
490
|
+
const target = [];
|
|
491
|
+
for (const p of parts) { if (p.kind === 'interp') target.push(PH); else for (const ch of p.value) target.push(ch); }
|
|
492
|
+
const out = [];
|
|
493
|
+
let i = 0;
|
|
494
|
+
for (let j = 0; j < target.length; j++) {
|
|
495
|
+
while (i < pre.length && pre[i].ch !== target[j] && /[ \t\n]/.test(pre[i].ch)) i++;
|
|
496
|
+
if (i < pre.length && pre[i].ch === target[j]) { out.push(pre[i]); i++; continue; }
|
|
497
|
+
approximate = true;
|
|
498
|
+
const ref = pre[Math.min(i, pre.length - 1)] || { src: node.start, srcEnd: node.end };
|
|
499
|
+
out.push({ ch: target[j], src: ref.src, srcEnd: ref.srcEnd, approx: true });
|
|
500
|
+
}
|
|
501
|
+
return { chars: out, interps, approximate, indented };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const genPos = (text, idx) => { let line = 1; let last = 0; for (let i = 0; i < idx; i++) if (text[i] === '\n') { line++; last = i + 1; } return { line, column: idx - last }; };
|
|
505
|
+
|
|
506
|
+
// ── shell lexer ──────────────────────────────────────────────────────────────
|
|
507
|
+
/**
|
|
508
|
+
* Lex generated shell. Returns placeholder contexts and `$VAR` expansions.
|
|
509
|
+
* context: unquoted | double | single | heredoc-expanding | heredoc-literal | comment | escaped
|
|
510
|
+
*/
|
|
511
|
+
export function lexShell(text) {
|
|
512
|
+
const phs = []; const exps = []; const cmds = [];
|
|
513
|
+
const stack = [{ m: 'normal' }];
|
|
514
|
+
let cmd = { words: [], start: 0 };
|
|
515
|
+
let word = null;
|
|
516
|
+
const pendingHeredocs = [];
|
|
517
|
+
let i = 0; const n = text.length;
|
|
518
|
+
const top = () => stack[stack.length - 1];
|
|
519
|
+
const startWord = (idx) => { if (!word) { word = { text: '', start: idx, phs: [], exps: [], quoted: false }; cmd.words.push(word); } return word; };
|
|
520
|
+
const endWord = () => { word = null; };
|
|
521
|
+
const endCmd = () => { endWord(); if (cmd.words.length) cmds.push(cmd); cmd = { words: [], start: i }; };
|
|
522
|
+
const ctxName = () => { const t = top(); return t.m === 'dq' ? 'double' : t.m === 'sq' ? 'single' : 'unquoted'; };
|
|
523
|
+
const atWordStart = () => !word;
|
|
524
|
+
while (i < n) {
|
|
525
|
+
const c = text[i]; const t = top();
|
|
526
|
+
if (t.m === 'sq') {
|
|
527
|
+
if (c === "'") { stack.pop(); i++; continue; }
|
|
528
|
+
if (c === PH) { const p = { idx: i, context: 'single', word: startWord(i), cmd }; word.phs.push(p); phs.push(p); word.text += PH; i++; continue; }
|
|
529
|
+
startWord(i).text += c; i++; continue;
|
|
530
|
+
}
|
|
531
|
+
if (t.m === 'dq') {
|
|
532
|
+
if (c === '\\' && i + 1 < n) { startWord(i).text += text[i + 1] === PH ? PH : c + text[i + 1]; if (text[i + 1] === PH) { const p = { idx: i + 1, context: 'double', escaped: true, word: word, cmd }; word.phs.push(p); phs.push(p); } i += 2; continue; }
|
|
533
|
+
if (c === '"') { stack.pop(); i++; continue; }
|
|
534
|
+
if (c === '`') { stack.push({ m: 'bt' }); i++; continue; }
|
|
535
|
+
if (c === '$' && text[i + 1] === '(') { stack.push({ m: 'cmdsub', depth: 1 }); i += 2; continue; }
|
|
536
|
+
if (c === PH) { const p = { idx: i, context: 'double', word: startWord(i), cmd }; word.phs.push(p); phs.push(p); word.text += PH; i++; continue; }
|
|
537
|
+
if (c === '$') { const m = /^\$(?:\{([A-Za-z_]\w*)[^}]*\}|([A-Za-z_]\w*)|(\d|[@*?#!$-]))/.exec(text.slice(i)); if (m) { const e = { name: m[1] || m[2] || m[3], idx: i, end: i + m[0].length, context: 'double', word: startWord(i), cmd }; word.exps.push(e); exps.push(e); word.text += m[0]; i += m[0].length; continue; } }
|
|
538
|
+
startWord(i).text += c; i++; continue;
|
|
539
|
+
}
|
|
540
|
+
// normal / cmdsub / backtick
|
|
541
|
+
if (c === '\\' && i + 1 < n) { if (text[i + 1] === '\n') { i += 2; continue; } const w = startWord(i); w.text += text[i + 1] === PH ? PH : text[i + 1]; if (text[i + 1] === PH) { const p = { idx: i + 1, context: 'unquoted', escaped: true, word: w, cmd }; w.phs.push(p); phs.push(p); } i += 2; continue; }
|
|
542
|
+
if (c === "'") { startWord(i).quoted = true; stack.push({ m: 'sq' }); i++; continue; }
|
|
543
|
+
if (c === '"') { startWord(i).quoted = true; stack.push({ m: 'dq' }); i++; continue; }
|
|
544
|
+
if (c === '`') { if (t.m === 'bt') { stack.pop(); } else stack.push({ m: 'bt' }); i++; continue; }
|
|
545
|
+
if (c === '$' && text[i + 1] === '(' ) { stack.push({ m: 'cmdsub', depth: 1 }); startWord(i); i += 2; continue; }
|
|
546
|
+
if (t.m === 'cmdsub') { if (c === '(') t.depth++; else if (c === ')') { t.depth--; if (t.depth === 0) { stack.pop(); i++; continue; } } }
|
|
547
|
+
if (c === '#' && atWordStart()) { let j = i; while (j < n && text[j] !== '\n') { if (text[j] === PH) phs.push({ idx: j, context: 'comment', word: null, cmd }); j++; } i = j; continue; }
|
|
548
|
+
if (c === '<' && text[i + 1] === '<' && text[i + 2] !== '<') {
|
|
549
|
+
const m = /^<<(-?)\s*(?:'([^']*)'|"([^"]*)"|\\?([A-Za-z_][\w.-]*))/.exec(text.slice(i));
|
|
550
|
+
if (m) { pendingHeredocs.push({ strip: !!m[1], delim: m[2] ?? m[3] ?? m[4], quoted: m[2] !== undefined || m[3] !== undefined || text[i + 2 + (m[1] ? 1 : 0)] === '\\' }); i += m[0].length; continue; }
|
|
551
|
+
}
|
|
552
|
+
if (c === '\n') {
|
|
553
|
+
endCmd();
|
|
554
|
+
i++;
|
|
555
|
+
while (pendingHeredocs.length) {
|
|
556
|
+
const h = pendingHeredocs.shift();
|
|
557
|
+
for (;;) {
|
|
558
|
+
if (i >= n) break;
|
|
559
|
+
let e = text.indexOf('\n', i); if (e < 0) e = n;
|
|
560
|
+
const line = text.slice(i, e);
|
|
561
|
+
if ((h.strip ? line.replace(/^\t+/, '') : line) === h.delim) { i = e + 1; break; }
|
|
562
|
+
for (let j = i; j < e; j++) {
|
|
563
|
+
if (text[j] === PH) phs.push({ idx: j, context: h.quoted ? 'heredoc-literal' : 'heredoc-expanding', word: null, cmd: null });
|
|
564
|
+
else if (!h.quoted && text[j] === '$') { const m = /^\$(?:\{([A-Za-z_]\w*)[^}]*\}|([A-Za-z_]\w*))/.exec(text.slice(j, e)); if (m) exps.push({ name: m[1] || m[2], idx: j, end: j + m[0].length, context: 'heredoc-expanding', word: null, cmd: null }); }
|
|
565
|
+
}
|
|
566
|
+
i = e + 1;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (c === ' ' || c === '\t' || c === '\r') { endWord(); i++; continue; }
|
|
572
|
+
if (c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || c === '{' && !word || c === '}' && !word) {
|
|
573
|
+
if ((c === '|' || c === '&') && text[i + 1] === c) { endCmd(); i += 2; continue; }
|
|
574
|
+
if (c === '|') { cmd.pipeNext = true; endCmd(); i++; continue; }
|
|
575
|
+
if (c === ';' || c === '&' || c === '(' || c === ')' || c === '{' || c === '}') { endCmd(); i++; continue; }
|
|
576
|
+
}
|
|
577
|
+
if (c === PH) { const p = { idx: i, context: ctxName(), word: startWord(i), cmd }; word.phs.push(p); phs.push(p); word.text += PH; i++; continue; }
|
|
578
|
+
if (c === '$') {
|
|
579
|
+
const m = /^\$(?:\{([A-Za-z_]\w*)[^}]*\}|([A-Za-z_]\w*)|(\d|[@*?#!$-]))/.exec(text.slice(i));
|
|
580
|
+
if (m) { const e = { name: m[1] || m[2] || m[3], idx: i, end: i + m[0].length, context: ctxName(), word: startWord(i), cmd }; word.exps.push(e); exps.push(e); word.text += m[0]; i += m[0].length; continue; }
|
|
581
|
+
}
|
|
582
|
+
startWord(i).text += c; i++;
|
|
583
|
+
}
|
|
584
|
+
endCmd();
|
|
585
|
+
// command naming
|
|
586
|
+
for (const cm of cmds) {
|
|
587
|
+
let k = 0;
|
|
588
|
+
while (k < cm.words.length && (/^[A-Za-z_]\w*=/.test(cm.words[k].text) || SKIP_WORDS.has(cm.words[k].text))) k++;
|
|
589
|
+
cm.nameIdx = k;
|
|
590
|
+
cm.name = k < cm.words.length && !cm.words[k].phs.length ? baseName(cm.words[k].text) : null;
|
|
591
|
+
cm.nameWord = cm.words[k] || null;
|
|
592
|
+
}
|
|
593
|
+
return { phs, exps, cmds };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function sinkOf(cm, wordIdx) {
|
|
597
|
+
if (!cm || cm.name === null) return { kind: 'argument', command: null };
|
|
598
|
+
const name = cm.name;
|
|
599
|
+
if (wordIdx === cm.nameIdx) return { kind: 'command-position', command: name === PH ? null : name };
|
|
600
|
+
if (EVAL_CMDS.has(name)) return { kind: 'eval', command: name };
|
|
601
|
+
if (SHELLS.has(name) && cm.words.some((w, j) => j > cm.nameIdx && /^-\w*c\w*$/.test(w.text))) return { kind: 'eval', command: `${name} -c` };
|
|
602
|
+
if (FS_CMDS.has(name)) return { kind: 'filesystem', command: name };
|
|
603
|
+
if (NET_CMDS.has(name)) return { kind: 'network', command: name };
|
|
604
|
+
return { kind: 'argument', command: name };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── script sites ─────────────────────────────────────────────────────────────
|
|
608
|
+
function shebangOf(chars) {
|
|
609
|
+
const first = chars.slice(0, 200).map((c) => c.ch).join('').split('\n')[0];
|
|
610
|
+
if (!/^#!/.test(first)) return null;
|
|
611
|
+
const t = first.replace(/^#!\s*/, '').replace(new RegExp(PH, 'g'), '').trim().split(/\s+/);
|
|
612
|
+
let prog = baseName(t[0]);
|
|
613
|
+
if (prog === 'env') prog = baseName(t.slice(1).find((x) => !x.startsWith('-')) || '');
|
|
614
|
+
return prog || baseName(first.replace(/^#!/, '').trim());
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function collectSites(ws, file) {
|
|
618
|
+
const rec = ws.parse(file);
|
|
619
|
+
if (!rec || !rec.ast) return { sites: [], gaps: [] };
|
|
620
|
+
const sites = []; const gaps = [];
|
|
621
|
+
const res = new Resolver(ws);
|
|
622
|
+
const visit = (node, env, attrPath, parent, depth, asFn = false) => {
|
|
623
|
+
if (!node || typeof node !== 'object' || depth > 200) return;
|
|
624
|
+
switch (node.type) {
|
|
625
|
+
case 'lambda': {
|
|
626
|
+
const vars = new Map();
|
|
627
|
+
if (node.param.kind === 'pattern') for (const f of node.param.formals) vars.set(f.name, { kind: 'param', name: f.name, span: f.span, file, def: f.default });
|
|
628
|
+
else if (node.param.name) vars.set(node.param.name, { kind: 'param', name: node.param.name, span: node.span, file });
|
|
629
|
+
if (node.param.atName) vars.set(node.param.atName, { kind: 'param', name: node.param.atName, span: node.span, file });
|
|
630
|
+
visit(node.body, mkEnv(env, vars, [], file), attrPath, node, depth + 1);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
case 'let': {
|
|
634
|
+
const e2 = mkEnv(env, bindingsEnv(node, env, file), [], file);
|
|
635
|
+
for (const b of node.bindings) if (b.kind === 'attr') visit(b.value, e2, [], node, depth + 1);
|
|
636
|
+
visit(node.body, e2, attrPath, node, depth + 1);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
case 'with': { visit(node.env, env, attrPath, node, depth + 1); visit(node.body, mkEnv(env, new Map(), [new Evaluator(res).withName(node.env)], file), attrPath, node, depth + 1); return; }
|
|
640
|
+
case 'attrset': {
|
|
641
|
+
const e2 = node.rec ? mkEnv(env, bindingsEnv(node, env, file), [], file) : env;
|
|
642
|
+
for (const b of node.bindings) {
|
|
643
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
644
|
+
const key = attrKey(b.path);
|
|
645
|
+
const full = [...attrPath, ...key];
|
|
646
|
+
const last = key[key.length - 1];
|
|
647
|
+
const v = unparen(b.value);
|
|
648
|
+
// `environment.etc."run.sh".text` (a name that says shell, or a shebang line) is a generated script file as well
|
|
649
|
+
const etcAt = full.indexOf('etc');
|
|
650
|
+
const etcScript = last === 'text' && etcAt >= 0 && etcAt < full.length - 2
|
|
651
|
+
&& (/\.(?:sh|bash)$/i.test(String(full[etcAt + 1] || '')) || (v && v.type === 'string' && v.parts && v.parts[0] && v.parts[0].kind === 'text' && /^#!\s*(?:\/\S*\/)?(?:env\s+)?(?:ba|da|z|k|a)?sh\b/.test(String(v.parts[0].value))));
|
|
652
|
+
const hasWritten = SHELL_ATTRS.has(last) || (last === 'text' && full.some((s) => s === 'activationScripts' || s === 'writeShellApplication')) || etcScript;
|
|
653
|
+
if (hasWritten) {
|
|
654
|
+
if (v && v.type === 'string') sites.push({ node: v, env: e2, path: full, kind: 'attr', parentAttrset: node, bindingSpan: b.span, shell: 'bash' });
|
|
655
|
+
else if (v && v.type === 'ident') { const d = res.deref(v, e2, []); if (d.node && d.node.type === 'string') sites.push({ node: d.node, env: d.env, path: full, kind: 'attr-via-binding', parentAttrset: node, bindingSpan: b.span, shell: 'bash', fileOverride: d.file }); else gaps.push({ kind: 'script-not-analyzable', file, line: b.span.startLine, detail: `${full.join('.')} is not a string literal Nix can be read statically (${v.type})` }); }
|
|
656
|
+
else if (v && v.type !== 'lambda' && !(v.type === 'attrset')) gaps.push({ kind: 'script-not-analyzable', file, line: b.span ? b.span.startLine : 1, detail: `${full.join('.')} is built by an expression (${v.type}); only string literals are analyzed` });
|
|
657
|
+
}
|
|
658
|
+
visit(b.value, e2, full, node, depth + 1);
|
|
659
|
+
}
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
case 'app': {
|
|
663
|
+
const ev = new Evaluator(res);
|
|
664
|
+
const { fn, args } = ev.flatten(node);
|
|
665
|
+
const c = ev.calleeOf(fn);
|
|
666
|
+
const b = !asFn && c.name && SHELL_BUILDERS.get(c.name);
|
|
667
|
+
if (b && args.length >= b.arg) {
|
|
668
|
+
// the script is the LAST argument (some writers take an options attrset before it)
|
|
669
|
+
const a = unparen(args[args.length - 1]);
|
|
670
|
+
if (a && a.type === 'string') sites.push({ node: a, env, path: [c.name], kind: 'builder', builder: c.name, parentAttrset: null, bindingSpan: a.span, shell: b.shell });
|
|
671
|
+
else if (args.length >= b.arg + 1) gaps.push({ kind: 'script-not-analyzable', file, line: (a && a.span && a.span.startLine) || 1, detail: `${c.name} script is built by an expression (${a && a.type}); only string literals are analyzed` });
|
|
672
|
+
}
|
|
673
|
+
if (!asFn && c.name === 'writeShellApplication') {
|
|
674
|
+
const arg = unparen(args[0]);
|
|
675
|
+
const hit = arg && arg.type === 'attrset' ? attrIn(arg, ['text']) : null;
|
|
676
|
+
const tv = hit && unparen(hit.value);
|
|
677
|
+
if (tv && tv.type === 'string') sites.push({ node: tv, env, path: [c.name, 'text'], kind: 'builder', builder: c.name, parentAttrset: arg, bindingSpan: hit.span, shell: 'bash' });
|
|
678
|
+
}
|
|
679
|
+
visit(node.fn, env, attrPath, node, depth + 1, true); visit(node.arg, env, attrPath, node, depth + 1);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
default: {
|
|
683
|
+
for (const k of ['left', 'right', 'operand', 'expr', 'cond', 'then', 'else', 'body', 'base']) if (node[k] && typeof node[k] === 'object') visit(node[k], env, attrPath, node, depth + 1);
|
|
684
|
+
if (node.items) for (const it of node.items) visit(it, env, attrPath, node, depth + 1);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
visit(rec.ast, mkEnv(null, new Map(), [], file), [], null, 0);
|
|
689
|
+
// an attr string that is ALSO inside a builder app can be reported twice: de-duplicate by node start
|
|
690
|
+
const seen = new Set(); const uniq = [];
|
|
691
|
+
for (const s of sites) { const k = `${s.fileOverride || file}:${s.node.start}`; if (!seen.has(k)) { seen.add(k); uniq.push(s); } }
|
|
692
|
+
return { sites: uniq, gaps, res };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ── analysis ─────────────────────────────────────────────────────────────────
|
|
696
|
+
const SEV = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
697
|
+
|
|
698
|
+
function threatOf(origins) {
|
|
699
|
+
const kinds = new Set(origins.filter((o) => !SAFE_KINDS.has(o.kind)).map((o) => o.kind));
|
|
700
|
+
if (kinds.has('attacker')) return 'attacker';
|
|
701
|
+
if (kinds.has('argument')) return 'argument';
|
|
702
|
+
if (kinds.has('configuration')) return 'configuration';
|
|
703
|
+
if (kinds.has('secret')) return 'secret';
|
|
704
|
+
if (kinds.has('unknown')) return 'unknown';
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const TYPED_TAIL = /\.(?:port|enable\w*|openFirewall|timeout\w*|count|size|level|interval|uid|gid|mode|verbose|debug)$/;
|
|
709
|
+
function severityFor(threat, sink, context, origins = []) {
|
|
710
|
+
if (threat === 'configuration' && origins.filter((o) => o.kind === 'configuration').every((o) => TYPED_TAIL.test(o.detail))) return 'low';
|
|
711
|
+
const cmdPos = sink.kind === 'command-position' || sink.kind === 'eval';
|
|
712
|
+
if (threat === 'attacker') return cmdPos ? 'critical' : 'high';
|
|
713
|
+
if (threat === 'argument') return cmdPos ? 'high' : 'medium';
|
|
714
|
+
if (threat === 'configuration') return cmdPos ? 'medium' : 'medium';
|
|
715
|
+
if (threat === 'secret') return 'low';
|
|
716
|
+
return 'low';
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const RULES = Object.freeze({
|
|
720
|
+
'nix-shell-injection': { cwe: 'CWE-78', family: 'cmdi', vuln: 'Nix interpolation reaches a shell command unescaped', remediation: 'Wrap the value in lib.escapeShellArg (unquoted position) or pass it as an environment variable and quote the shell expansion.' },
|
|
721
|
+
'nix-escape-wrong-context': { cwe: 'CWE-116', family: 'wrong-context-escape', vuln: 'escapeShellArg used inside quotes (wrong shell context)', remediation: 'lib.escapeShellArg produces its own single quotes: place it in an UNQUOTED position, or drop the surrounding quotes.' },
|
|
722
|
+
'nix-escape-wrong-kind': { cwe: 'CWE-116', family: 'wrong-context-escape', vuln: 'Escaping function does not escape for the shell', remediation: 'Use lib.escapeShellArg / lib.escapeShellArgs for shell words.' },
|
|
723
|
+
'nix-service-env-shell': { cwe: 'CWE-78', family: 'cmdi', vuln: 'Service environment value expanded unquoted in a generated script', remediation: 'Quote the expansion ("$NAME") and validate or escape the value where it is set.' },
|
|
724
|
+
'nix-shell-eval': { cwe: 'CWE-95', family: 'cmdi', vuln: 'Shell evaluates a variable as code in a generated script', remediation: 'Do not eval or `sh -c` a variable; pass arguments directly.' },
|
|
725
|
+
'nix-shell-unquoted-expansion': { cwe: 'CWE-78', family: 'cmdi', vuln: 'Unquoted shell expansion in a destructive command', remediation: 'Quote the expansion ("$VAR") and guard against empty values (${VAR:?}).' },
|
|
726
|
+
'nix-pipe-to-shell': { cwe: 'CWE-494', family: 'download-exec', vuln: 'Network download piped into a shell', remediation: 'Fetch with a pinned hash (fetchurl) and run the result from the store.' },
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
export function analyzeNixScripts(opts = {}) {
|
|
730
|
+
const files = {};
|
|
731
|
+
for (const [p, t] of Object.entries(opts.files || {})) if (typeof t === 'string' && /\.nix$/i.test(p) && !isLanguageExcludedPath(p)) files[p] = t;
|
|
732
|
+
const ws = new Workspace(files);
|
|
733
|
+
const findings = []; const flows = []; const gaps = []; const scripts = [];
|
|
734
|
+
const res = new Resolver(ws);
|
|
735
|
+
const ev = new Evaluator(res);
|
|
736
|
+
|
|
737
|
+
for (const file of Object.keys(files).sort()) {
|
|
738
|
+
const rec = ws.parse(file);
|
|
739
|
+
if (!rec || !rec.ast) { gaps.push({ kind: 'parse-failed', file, detail: rec ? `parse status ${rec.parse.status}` : 'unreadable' }); continue; }
|
|
740
|
+
const { sites, gaps: g } = collectSites(ws, file);
|
|
741
|
+
gaps.push(...g);
|
|
742
|
+
for (const site of sites) {
|
|
743
|
+
const sfile = site.fileOverride || file;
|
|
744
|
+
const srec = ws.parse(sfile);
|
|
745
|
+
const dec = decodeString(srec, site.node);
|
|
746
|
+
const text = dec.chars.map((c) => c.ch).join('');
|
|
747
|
+
const shebang = shebangOf(dec.chars);
|
|
748
|
+
const shellName = shebang || (site.shell === 'shebang' ? null : site.shell);
|
|
749
|
+
const loc = (idx0, idx1) => {
|
|
750
|
+
const a = genPos(text, idx0); const b = genPos(text, idx1);
|
|
751
|
+
return { file: sfile, line: a.line, startColumn: a.column, endLine: b.line, endColumn: b.column, generated: true };
|
|
752
|
+
};
|
|
753
|
+
const srcSpanOf = (idx0, idx1) => {
|
|
754
|
+
const first = dec.chars[idx0]; const last = dec.chars[Math.max(idx0, idx1 - 1)];
|
|
755
|
+
if (!first || !last) return null;
|
|
756
|
+
const approx = !!(first.approx || last.approx);
|
|
757
|
+
const s = first.src; const e = Math.max(last.srcEnd, s + 1);
|
|
758
|
+
return { ...srec.loc.span(s, e), approximate: approx };
|
|
759
|
+
};
|
|
760
|
+
const script = { file: sfile, attrPath: site.path.join('.'), kind: site.kind, shell: shellName, generated: text, span: spanOf(site.node), decoded: dec.approximate ? 'approximate' : 'exact', map: (a, b) => srcSpanOf(a, b), placeholders: dec.interps.length };
|
|
761
|
+
scripts.push(script);
|
|
762
|
+
if (shellName && !SUPPORTED_SHELLS.has(shellName)) {
|
|
763
|
+
gaps.push({ kind: SHELLS.has(shellName) ? 'unsupported-shell' : 'unsupported-script-language', file: sfile, line: spanOf(site.node) ? site.node.span.startLine : 1, detail: `${site.path.join('.')} is a ${shellName} script: it is not analyzed (only ${[...SUPPORTED_SHELLS].join('/')} is modelled), so no verdict about it should be read as coverage` });
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (dec.approximate) gaps.push({ kind: 'approximate-location-map', file: sfile, line: site.node.span.startLine, detail: 'escape sequences prevented an exact character map; locations point at the enclosing string segment' });
|
|
767
|
+
const lex = lexShell(text);
|
|
768
|
+
const siteFile = sfile;
|
|
769
|
+
|
|
770
|
+
// origins per interpolation
|
|
771
|
+
const originOf = new Map();
|
|
772
|
+
for (const it of dec.interps) {
|
|
773
|
+
const r = ev.eval(it.expr, site.env, 0);
|
|
774
|
+
originOf.set(it.index, r);
|
|
775
|
+
}
|
|
776
|
+
const envMap = collectEnvironment(site, ev);
|
|
777
|
+
|
|
778
|
+
const emit = (rule, o) => {
|
|
779
|
+
const R = RULES[rule];
|
|
780
|
+
const f = {
|
|
781
|
+
id: `NIX-SCRIPT-${rule}-${siteFile}:${o.line}:${o.column}`,
|
|
782
|
+
severity: o.severity, file: siteFile, line: o.line, column: o.column, vuln: o.vuln || R.vuln, cwe: R.cwe,
|
|
783
|
+
description: o.description, remediation: o.remediation || R.remediation, parser: 'NIX-SCRIPT', family: R.family, rule,
|
|
784
|
+
language: 'nix', capability: 'sast', analysisKind: 'config', evidenceKind: 'source', confidence: o.confidence ?? 0.8,
|
|
785
|
+
originalLocation: o.originalLocation, generatedLocation: o.generatedLocation, chain: o.chain,
|
|
786
|
+
origins: o.origins.map((x) => ({ kind: x.kind, detail: x.detail })), sink: o.sink, protection: o.protection || null,
|
|
787
|
+
shell: shellName || 'bash', attrPath: script.attrPath,
|
|
788
|
+
bridge: null, applicationFlow: 'not-inferred',
|
|
789
|
+
...(o.uncertainty ? { uncertainty: o.uncertainty } : {}), ...(o.extra || {}),
|
|
790
|
+
};
|
|
791
|
+
findings.push(f);
|
|
792
|
+
return f;
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
for (const p of lex.phs) {
|
|
796
|
+
const it = dec.interps[dec.chars[p.idx].ph];
|
|
797
|
+
const r = originOf.get(it.index);
|
|
798
|
+
const spanNix = it.span;
|
|
799
|
+
const gloc = loc(p.idx, p.idx + 1);
|
|
800
|
+
const sink = sinkOf(p.cmd, p.cmd && p.word ? p.cmd.words.indexOf(p.word) : -1);
|
|
801
|
+
const nextCh = text[p.idx + 1];
|
|
802
|
+
let origins = r.origins;
|
|
803
|
+
// a bare argument/unknown used as a path prefix ("${pkg}/bin/x") is a store path use
|
|
804
|
+
if (nextCh === '/') origins = origins.map((o) => (o.kind === 'argument' || o.kind === 'unknown' ? mkOrigin('store', `${o.detail} used as a path prefix`, o.chain) : o));
|
|
805
|
+
const threat = threatOf(origins);
|
|
806
|
+
const chain = [...originChain(origins), hop(sfile, spanNix, `interpolation \${${srec.text.slice(it.expr.start, it.expr.end).replace(/\s+/g, ' ').slice(0, 60)}}`), { file: sfile, line: gloc.line, column: gloc.startColumn, endLine: gloc.endLine, endColumn: gloc.endColumn, label: `shell ${sink.kind}${sink.command ? ` (${sink.command})` : ''}`, kind: 'shell', generated: true }].filter(Boolean);
|
|
807
|
+
const base = { line: spanNix.startLine, column: spanNix.startColumn, originalLocation: spanNix, generatedLocation: gloc, chain, origins, sink: { ...sink, context: p.context }, protection: r.protection };
|
|
808
|
+
flows.push({ file: sfile, line: spanNix.startLine, origins: origins.map((x) => x.kind), originDetails: origins.map((x) => ({ kind: x.kind, detail: x.detail })), context: p.context, sink, protection: r.protection, protectedInContext: !!r.protection && p.context === 'unquoted', rule: null, originalLocation: spanNix, generatedLocation: gloc, chain, attrPath: site.path.join('.'), scriptHasTrace: /^\s*set\s+-\w*x/m.test(text) });
|
|
809
|
+
if (p.context === 'comment' || p.context === 'heredoc-literal' && !threat) continue;
|
|
810
|
+
if (!threat) continue;
|
|
811
|
+
if (r.wrongEscape) { emit('nix-escape-wrong-kind', { ...base, severity: 'medium', description: `${r.wrongEscape} does not escape for the shell, but the value reaches a shell script (${p.context}).` }); flows[flows.length - 1].rule = 'wrong-kind'; continue; }
|
|
812
|
+
if (r.protection) {
|
|
813
|
+
if (p.context === 'unquoted') { flows[flows.length - 1].rule = 'protected'; continue; } // correct use
|
|
814
|
+
emit('nix-escape-wrong-context', { ...base, severity: threat === 'attacker' ? 'high' : 'medium', description: `${r.protection} is applied but the interpolation sits ${p.context === 'single' ? "inside single quotes ('...')" : p.context === 'double' ? 'inside double quotes ("...")' : `in a ${p.context} context`}: the escaping adds its own quotes, which breaks out of or is neutralised by the surrounding ones${p.context === 'double' || p.context === 'heredoc-expanding' ? ', and $(...)/backticks are still expanded' : ''}.`, protection: r.protection });
|
|
815
|
+
flows[flows.length - 1].rule = 'wrong-context';
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (threat === 'secret' && p.context !== 'unquoted') continue; // placement of secrets is NIX-006; here only injection is judged
|
|
819
|
+
const unknownOnly = threat === 'unknown';
|
|
820
|
+
const sev = severityFor(threat, sink, p.context, origins);
|
|
821
|
+
const ctxText = { unquoted: 'unquoted', double: 'inside double quotes', single: 'inside single quotes', 'heredoc-expanding': 'in an expanding heredoc' }[p.context] || p.context;
|
|
822
|
+
emit('nix-shell-injection', { ...base, severity: sev, description: `A ${threat === 'unknown' ? 'value whose origin could not be resolved' : `${threat}-origin value`} is interpolated ${ctxText} into ${sink.kind === 'command-position' ? 'command position' : `${sink.command ? `an argument of ${sink.command}` : 'a shell command'}`} without lib.escapeShellArg. ${threat === 'configuration' ? 'It is set by the configuration, not by an attacker, so exploitation needs control of that configuration.' : threat === 'argument' ? 'It is a caller-supplied function argument.' : ''}`.trim(), confidence: unknownOnly ? 0.3 : (threat === 'configuration' ? 0.6 : 0.8), ...(unknownOnly ? { uncertainty: [{ kind: 'unresolved-target', detail: origins.find((o) => o.kind === 'unknown').detail }] } : {}) });
|
|
823
|
+
flows[flows.length - 1].rule = 'injection';
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// shell variable expansions in the generated text: service environment and eval-like use
|
|
827
|
+
for (const e of lex.exps) {
|
|
828
|
+
const word = e.word; const cm = e.cmd;
|
|
829
|
+
const wIdx = cm && word ? cm.words.indexOf(word) : -1;
|
|
830
|
+
const sink = sinkOf(cm, wIdx);
|
|
831
|
+
const srcSpan = srcSpanOf(e.idx, e.end);
|
|
832
|
+
if (!srcSpan) continue;
|
|
833
|
+
const gloc = loc(e.idx, e.end);
|
|
834
|
+
const envHit = envMap.get(e.name);
|
|
835
|
+
const unquoted = e.context === 'unquoted';
|
|
836
|
+
const destructive = cm && /^(?:rm|chmod|chown|mv|cp|ln)$/.test(cm.name || '') && cm.words.some((w) => /^-\w*[rRf]\w*$/.test(w.text));
|
|
837
|
+
const evalLike = sink.kind === 'eval' || (cm && cm.name === 'eval');
|
|
838
|
+
const envThreat = envHit ? threatOf(envHit.result.origins) : null;
|
|
839
|
+
if (envHit && envThreat && (unquoted || evalLike) && sink.kind !== 'argument' || envHit && envThreat && unquoted && (destructive || sink.kind === 'filesystem' || sink.kind === 'network')) {
|
|
840
|
+
const sev = envThreat === 'attacker' ? 'high' : envThreat === 'argument' ? 'medium' : envThreat === 'configuration' ? 'medium' : 'low';
|
|
841
|
+
const chain = [...originChain(envHit.result.origins), hop(sfile, envHit.span, `environment.${e.name}`), { file: sfile, line: gloc.line, column: gloc.startColumn, endLine: gloc.endLine, endColumn: gloc.endColumn, label: `shell expansion $${e.name} (${sink.kind}${sink.command ? `: ${sink.command}` : ''})`, kind: 'shell', generated: true }].filter(Boolean);
|
|
842
|
+
emit('nix-service-env-shell', { line: srcSpan.startLine, column: srcSpan.startColumn, severity: sev, description: `Service environment variable ${e.name} (${envThreat}-origin) is expanded ${unquoted ? 'unquoted' : e.context} as ${sink.kind === 'command-position' ? 'a command' : `an argument of ${sink.command || 'a command'}`} in the generated script.`, originalLocation: srcSpan, generatedLocation: gloc, chain, origins: envHit.result.origins, sink: { ...sink, context: e.context }, extra: { environmentVariable: e.name, environmentSource: hop(sfile, envHit.span, `environment.${e.name}`) } });
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
if (unquoted && evalLike && cm && (cm.name === 'eval' || SHELLS.has(cm.name))) emit('nix-shell-eval', { line: srcSpan.startLine, column: srcSpan.startColumn, severity: 'medium', description: `The script evaluates $${e.name} as shell code (${cm.name}).`, originalLocation: srcSpan, generatedLocation: gloc, chain: [hop(sfile, srcSpan, `shell expansion $${e.name}`)].filter(Boolean), origins: [mkOrigin('unknown', `shell variable ${e.name}`)], sink: { ...sink, context: e.context }, confidence: 0.5 });
|
|
846
|
+
else if (unquoted && destructive && e.name !== '?' && !/^\d$/.test(e.name) && !(envHit && !envThreat)) emit('nix-shell-unquoted-expansion', { line: srcSpan.startLine, column: srcSpan.startColumn, severity: 'low', description: `$${e.name} is unquoted in ${cm.name} with a recursive/forced flag: word splitting or an empty value changes which paths are affected.`, originalLocation: srcSpan, generatedLocation: gloc, chain: [hop(sfile, srcSpan, `shell expansion $${e.name}`)].filter(Boolean), origins: [mkOrigin('unknown', `shell variable ${e.name}`)], sink: { ...sink, context: e.context }, confidence: 0.4 });
|
|
847
|
+
}
|
|
848
|
+
for (const m of text.matchAll(/(?:curl|wget)\b[^\n|;&]*\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b/g)) {
|
|
849
|
+
const span = srcSpanOf(m.index, m.index + m[0].length); if (!span) continue;
|
|
850
|
+
const gloc = loc(m.index, m.index + m[0].length);
|
|
851
|
+
emit('nix-pipe-to-shell', { line: span.startLine, column: span.startColumn, severity: 'medium', description: 'A network download is piped straight into a shell inside a generated script.', originalLocation: span, generatedLocation: gloc, chain: [{ file: sfile, line: gloc.line, column: gloc.startColumn, endLine: gloc.endLine, endColumn: gloc.endColumn, label: 'shell pipeline', kind: 'shell', generated: true }], origins: [mkOrigin('attacker', 'content downloaded at build/activation time')], sink: { kind: 'eval', command: 'sh', context: 'unquoted' }, confidence: 0.7 });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
const order = (a, b) => (SEV[b.severity] - SEV[a.severity]) || a.file.localeCompare(b.file) || a.line - b.line;
|
|
856
|
+
findings.sort(order);
|
|
857
|
+
return { version: NIX_SCRIPT_VERSION, findings, flows, gaps, scripts: scripts.map(({ map, ...s }) => Object.defineProperty(s, 'mapRange', { value: map, enumerable: false })), analyzed: scripts.length };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function originChain(origins) {
|
|
861
|
+
const out = [];
|
|
862
|
+
for (const o of origins) if (!SAFE_KINDS.has(o.kind)) for (const h of o.chain || []) if (h) out.push(h);
|
|
863
|
+
const seen = new Set();
|
|
864
|
+
return out.filter((h) => { const k = `${h.file}:${h.line}:${h.column}:${h.label}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** systemd `environment` attrset next to the script: NAME -> evaluated value. */
|
|
868
|
+
function collectEnvironment(site, ev) {
|
|
869
|
+
const map = new Map();
|
|
870
|
+
const parent = site.parentAttrset;
|
|
871
|
+
if (!parent || parent.type !== 'attrset') return map;
|
|
872
|
+
const add = (name, valueNode, span) => { map.set(name, { result: ev.eval(valueNode, site.env, 0), span }); };
|
|
873
|
+
for (const b of parent.bindings) {
|
|
874
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
875
|
+
const k = attrKey(b.path);
|
|
876
|
+
if (k[0] === 'environment' && k.length === 2 && k[1]) add(k[1], b.value, b.span);
|
|
877
|
+
else if (k[0] === 'environment' && k.length === 1) {
|
|
878
|
+
const set = unparen(b.value);
|
|
879
|
+
if (set && set.type === 'attrset') for (const eb of set.bindings) if (eb.kind === 'attr' && eb.value) { const ek = attrKey(eb.path); if (ek.length === 1 && ek[0]) add(ek[0], eb.value, eb.span); }
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return map;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export { PH as SHELL_PLACEHOLDER, RULES as NIX_SCRIPT_RULES, Workspace, Resolver, Evaluator, mkEnv, bindingsEnv, unparen, attrKey, segName, attrIn, hop, mkOrigin, SAFE_KINDS, SECRET_NAME, PATHISH_TAIL, CONFIG_PARAMS, RUNTIME_SECRET_DIRS, spanOf, lookup, dedupe };
|