@clear-capabilities/agentic-security-scanner 0.153.1 → 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 +67 -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 +18 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +140 -19
- 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/code-injection-multilang.js +15 -0
- package/src/sast/go-extended.js +11 -0
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Nix secret placement and configuration lineage (NIX-006).
|
|
2
|
+
//
|
|
3
|
+
// The Nix store is world-readable and everything Nix evaluates into a derivation, a unit file or a
|
|
4
|
+
// generated config ends up in it. This module follows credential-bearing values to where they land:
|
|
5
|
+
//
|
|
6
|
+
// plaintext-source a credential literal written in the Nix source itself
|
|
7
|
+
// store the value reaches a store path: writeText/toFile content, systemd unit
|
|
8
|
+
// environment, environment.etc text, a generated script, a Home Manager file
|
|
9
|
+
// build the value is a derivation attribute (it is in the .drv environment) or a build input
|
|
10
|
+
// log the value is printed (echo / logger / trace) by a script or by evaluation
|
|
11
|
+
//
|
|
12
|
+
// Runtime references are SAFE for the boundary they model: `passwordFile = config.sops.secrets.x.path`,
|
|
13
|
+
// "/run/secrets/x", EnvironmentFile and LoadCredential point at a file that exists decrypted only at
|
|
14
|
+
// runtime; sops/agenix ciphertext files are not plaintext. A manager name never sanitises every
|
|
15
|
+
// consumer, though: `builtins.readFile config.sops.secrets.x.path` is a deliberate decrypt-and-copy and
|
|
16
|
+
// still fires, because the decrypted content is now a store value.
|
|
17
|
+
//
|
|
18
|
+
// Nothing is decrypted, read from the host, or discovered by crawling: only the files handed in are
|
|
19
|
+
// looked at, and a finding never carries the secret value, only its source, destination, kind and length.
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
Workspace, Resolver, Evaluator, mkEnv, bindingsEnv, unparen, attrKey, segName, attrIn, hop, mkOrigin, SAFE_KINDS,
|
|
23
|
+
SECRET_NAME, PATHISH_TAIL, CONFIG_PARAMS, RUNTIME_SECRET_DIRS, spanOf, analyzeNixScripts,
|
|
24
|
+
} from './nix-script-taint.js';
|
|
25
|
+
import { isLanguageExcludedPath } from './discovery.js';
|
|
26
|
+
|
|
27
|
+
export const NIX_SECRETS_VERSION = 'nix-secrets/1';
|
|
28
|
+
|
|
29
|
+
const TOKEN_FORMATS = [
|
|
30
|
+
['private key block', /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----/, 'critical'],
|
|
31
|
+
['AWS access key id', /\bAKIA[0-9A-Z]{16}\b/, 'critical'],
|
|
32
|
+
['GitHub token', /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{40,}\b/, 'critical'],
|
|
33
|
+
['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, 'high'],
|
|
34
|
+
['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,}\b/, 'critical'],
|
|
35
|
+
['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/, 'high'],
|
|
36
|
+
['API secret key', /\bsk-(?:live-|proj-|ant-)?[A-Za-z0-9_-]{24,}\b/, 'high'],
|
|
37
|
+
];
|
|
38
|
+
const PLACEHOLDER = /^(?:|<[^>]*>|@[A-Za-z0-9_]+@|\$\{?[A-Za-z_]\w*\}?|%[A-Za-z_]+%|changeme|change-me|replaceme|todo|xxx+|\*+|null|none|example|dummy|placeholder|your[-_ ]?\w*|secret|password)$/i;
|
|
39
|
+
const ENV_DESTS = new Set(['environment']);
|
|
40
|
+
const TRACE_FNS = new Set(['trace', 'traceVal', 'traceValFn', 'traceSeq', 'traceShow', 'warn', 'traceIf', 'traceSeqN', 'traceValSeq', 'traceVerbose']);
|
|
41
|
+
const WRITERS = new Map([
|
|
42
|
+
['writeText', 1], ['writeTextFile', null], ['writeShellScript', 1], ['writeShellScriptBin', 1], ['writeScript', 1], ['writeScriptBin', 1], ['writeBash', 1], ['writeBashBin', 1],
|
|
43
|
+
['toFile', 1], ['writeTextDir', 1], ['runCommand', 2], ['runCommandLocal', 2], ['runCommandNoCC', 2], ['writeShellApplication', null],
|
|
44
|
+
]);
|
|
45
|
+
const DERIVATION_FNS = new Set(['mkDerivation', 'buildGoModule', 'buildPythonPackage', 'buildPythonApplication', 'buildNpmPackage', 'rustPlatform.buildRustPackage', 'buildRustPackage', 'stdenv.mkDerivation', 'runCommand', 'runCommandLocal', 'runCommandNoCC', 'mkShell', 'buildEnv', 'derivation', 'dockerTools.buildImage', 'buildImage', 'buildLayeredImage']);
|
|
46
|
+
const SEV = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
47
|
+
|
|
48
|
+
const secretName = (n) => typeof n === 'string' && SECRET_NAME.test(n) && !PATHISH_TAIL.test(n);
|
|
49
|
+
|
|
50
|
+
export const SECRET_RULES = Object.freeze({
|
|
51
|
+
'nix-secret-plaintext': { cwe: 'CWE-798', family: 'hardcoded-secret', vuln: 'Credential written as a literal in Nix source', remediation: 'Keep the value out of the Nix source: reference a runtime file (sops-nix/agenix path, LoadCredential) instead.' },
|
|
52
|
+
'nix-secret-store': { cwe: 'CWE-312', family: 'secret-in-store', vuln: 'Secret value reaches the world-readable Nix store', remediation: 'Pass a runtime file reference (…File option, EnvironmentFile, LoadCredential) instead of the value, so the secret is never part of a store path.' },
|
|
53
|
+
'nix-secret-build': { cwe: 'CWE-312', family: 'secret-in-build', vuln: 'Secret is a derivation input or environment variable', remediation: 'Do not pass secrets into derivations: they are recorded in the .drv and the build sandbox output. Read them at runtime.' },
|
|
54
|
+
'nix-secret-log': { cwe: 'CWE-532', family: 'secret-in-log', vuln: 'Secret value is printed to a log or trace', remediation: 'Do not echo or trace secrets; log a fingerprint or a reference instead.' },
|
|
55
|
+
'nix-secret-decrypt-copy': { cwe: 'CWE-312', family: 'secret-in-store', vuln: 'Decrypted secret copied into a store value at evaluation time', remediation: 'A sops-nix/agenix path exists only at runtime. Reading it during evaluation copies the decrypted content into the store; hand the path to the service instead.' },
|
|
56
|
+
'nix-secret-plaintext-file': { cwe: 'CWE-312', family: 'hardcoded-secret', vuln: 'Plaintext credentials file used where an encrypted file is expected', remediation: 'Encrypt the file (sops/age) before committing; the manager option only protects an encrypted file.' },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const redact = (value) => ({ redacted: true, length: typeof value === 'string' ? value.length : null });
|
|
60
|
+
|
|
61
|
+
/** Is this literal a plausible credential (not empty, placeholder, a path or a reference)? */
|
|
62
|
+
export function plausibleSecret(v) {
|
|
63
|
+
if (typeof v !== 'string') return false;
|
|
64
|
+
const t = v.trim();
|
|
65
|
+
if (t.length < 4) return false;
|
|
66
|
+
if (PLACEHOLDER.test(t)) return false;
|
|
67
|
+
if (/^\/(?:run|var|etc|nix|home|usr|opt|tmp)\//.test(t) || /^\.{0,2}\//.test(t)) return false;
|
|
68
|
+
if (/^(?:https?|file|unix):\/\/[^:@\s]*$/.test(t)) return false;
|
|
69
|
+
if (/^\$\{|^\$[A-Za-z_]/.test(t)) return false;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Classify where an attribute path lands. */
|
|
74
|
+
function destOf(path) {
|
|
75
|
+
const p = path.map((x) => x || '*');
|
|
76
|
+
const has = (...xs) => xs.every((x) => p.includes(x));
|
|
77
|
+
const last = p[p.length - 1];
|
|
78
|
+
if (p[0] === 'systemd' && (has('environment') || (last === 'Environment' || last === 'SetCredential'))) return { exposure: 'store', label: 'systemd unit file' };
|
|
79
|
+
if (p[0] === 'environment' && (p[1] === 'etc' || p[1] === 'variables' || p[1] === 'sessionVariables' || p[1] === 'shellAliases')) return { exposure: 'store', label: p[1] === 'etc' ? '/etc file built from the store' : 'system-wide environment' };
|
|
80
|
+
if (p[0] === 'home' && (p[1] === 'file' || p[1] === 'sessionVariables')) return { exposure: 'store', label: 'Home Manager file' };
|
|
81
|
+
if (p[0] === 'users' && p[1] === 'users' && /^(?:password|initialPassword|hashedPassword|initialHashedPassword)$/.test(last)) return { exposure: 'store', label: 'user account definition' };
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class Walker {
|
|
86
|
+
constructor(files) {
|
|
87
|
+
this.files = files; this.ws = new Workspace(files); this.res = new Resolver(this.ws); this.ev = new Evaluator(this.res);
|
|
88
|
+
this.findings = []; this.safe = []; this.gaps = []; this.seen = new Set(); this.scriptKeys = new Set();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
isRuntimeRef(expr, env) {
|
|
92
|
+
const n = unparen(expr);
|
|
93
|
+
if (!n) return false;
|
|
94
|
+
if (n.type === 'string' && !n.interpolated && RUNTIME_SECRET_DIRS.test(n.literal || '')) return true;
|
|
95
|
+
if (n.type === 'string' && n.interpolated) {
|
|
96
|
+
// "tok:${config.sops.secrets.tok.path}" / "${config.age.secrets.x.path}": only runtime path references and text
|
|
97
|
+
const parts = n.parts.filter((p) => p.kind === 'interp');
|
|
98
|
+
return parts.length > 0 && parts.every((p) => this.isRuntimeRef(p.expr, env));
|
|
99
|
+
}
|
|
100
|
+
if (n.type === 'select') {
|
|
101
|
+
const base = unparen(n.base); const segs = n.attrpath.map(segName);
|
|
102
|
+
if (base && base.type === 'ident' && CONFIG_PARAMS.has(base.name) && segs.every((x) => x !== null)) {
|
|
103
|
+
if (/^(?:sops|age|agenix)$/.test(segs[0]) && (segs.includes('placeholder') || segs.includes('templates') || (segs.includes('secrets') && /^(?:path|name)$/.test(segs[segs.length - 1])))) return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (n.type === 'ident') { const d = this.res.deref(n, env, []); if (d.node && d.node !== n) return this.isRuntimeRef(d.node, d.env); }
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Secret origin of an expression, after discounting runtime references. */
|
|
111
|
+
secretOrigins(expr, env) {
|
|
112
|
+
const r = this.ev.eval(expr, env, 0);
|
|
113
|
+
const kept = r.origins.filter((o) => o.kind === 'secret' && !/^option config\.(?:sops|age|agenix)\./.test(o.detail));
|
|
114
|
+
return { ...r, secret: kept };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
emit(rule, o) {
|
|
118
|
+
const R = SECRET_RULES[rule];
|
|
119
|
+
// one exposure per location (build wins over the generic store view of the same attribute); log is separate
|
|
120
|
+
const key = `${rule === 'nix-secret-log' ? 'log' : 'value'}|${o.file}|${o.line}|${o.column}`;
|
|
121
|
+
if (this.seen.has(key)) return null;
|
|
122
|
+
this.seen.add(key);
|
|
123
|
+
const f = {
|
|
124
|
+
id: `NIX-SECRET-${rule}-${o.file}:${o.line}:${o.column}`, severity: o.severity, file: o.file, line: o.line, column: o.column,
|
|
125
|
+
vuln: R.vuln, cwe: R.cwe, description: o.description, remediation: R.remediation, parser: 'NIX-SECRET', family: R.family, rule,
|
|
126
|
+
language: 'nix', capability: 'secrets', analysisKind: 'config', evidenceKind: 'source', confidence: o.confidence ?? 0.8,
|
|
127
|
+
exposure: o.exposure, source: o.source, destination: o.destination || null, evidence: o.evidence || { redacted: true },
|
|
128
|
+
originalLocation: o.originalLocation || null, chain: o.chain || [], origins: (o.origins || []).map((x) => ({ kind: x.kind, detail: x.detail })),
|
|
129
|
+
secretValue: 'redacted',
|
|
130
|
+
};
|
|
131
|
+
this.findings.push(f);
|
|
132
|
+
return f;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
at(file, span) { return span ? { file, line: span.startLine, column: span.startColumn } : { file, line: 1, column: 0 }; }
|
|
136
|
+
|
|
137
|
+
// ── pieces ────────────────────────────────────────────────────────────────
|
|
138
|
+
inspectLiteral(file, full, v, span, destination) {
|
|
139
|
+
const last = full[full.length - 1];
|
|
140
|
+
const text = v.literal;
|
|
141
|
+
for (const [label, re, sev] of TOKEN_FORMATS) {
|
|
142
|
+
if (re.test(text)) {
|
|
143
|
+
this.emit('nix-secret-plaintext', { ...this.at(file, v.span || span), file, severity: sev, exposure: 'plaintext-source', description: `A ${label} is written as a literal at ${full.filter(Boolean).join('.') || 'a string'} (value redacted).`, source: { kind: 'literal', file, line: (v.span || span).startLine, attr: full.filter(Boolean).join('.') }, destination: destination || { kind: 'source', label: 'Nix source (and every store path that embeds it)' }, evidence: { ...redact(text), format: label }, originalLocation: v.span || span, chain: [hop(file, v.span || span, `literal ${label}`)].filter(Boolean), origins: [mkOrigin('secret', `literal ${label}`)] });
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (secretName(last) && plausibleSecret(text)) {
|
|
148
|
+
const hashed = /hash/i.test(last);
|
|
149
|
+
this.emit('nix-secret-plaintext', { ...this.at(file, v.span || span), file, severity: hashed ? 'medium' : 'high', exposure: 'plaintext-source', description: `${full.filter(Boolean).join('.')} is assigned a ${hashed ? 'password hash' : 'credential'} literal (value redacted); it is part of the Nix source and of every store path built from it.`, source: { kind: 'literal', file, line: (v.span || span).startLine, attr: full.filter(Boolean).join('.') }, destination: destination || { kind: 'source', label: 'Nix source (and every store path that embeds it)' }, evidence: redact(text), originalLocation: v.span || span, chain: [hop(file, v.span || span, `literal assigned to ${last}`)].filter(Boolean), origins: [mkOrigin('secret', `literal credential in ${last}`)], confidence: hashed ? 0.6 : 0.8 });
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** key=value secrets inside literal text (a config file body, an Environment entry). */
|
|
156
|
+
inspectText(file, node, full, destination, env) {
|
|
157
|
+
if (!node || node.type !== 'string') return;
|
|
158
|
+
for (const part of node.parts) {
|
|
159
|
+
if (part.kind !== 'text') continue;
|
|
160
|
+
for (const m of part.value.matchAll(/(?:^|[\s"'])([A-Za-z_][\w.-]*)\s*[=:]\s*["']?([^\s"'$@][^\s"']{3,})/g)) {
|
|
161
|
+
if (secretName(m[1]) && plausibleSecret(m[2]) && !RUNTIME_SECRET_DIRS.test(m[2])) {
|
|
162
|
+
this.emit('nix-secret-plaintext', { ...this.at(file, node.span), file, severity: 'high', exposure: destination ? destination.exposure : 'plaintext-source', description: `A generated text contains ${m[1]}=… with a literal credential (value redacted).`, source: { kind: 'literal', file, line: node.span.startLine, attr: full.filter(Boolean).join('.') }, destination: destination || { kind: 'source', label: 'Nix source' }, evidence: { ...redact(m[2]), key: m[1] }, originalLocation: node.span, chain: [hop(file, node.span, `literal ${m[1]}=…`)].filter(Boolean), origins: [mkOrigin('secret', `literal ${m[1]}`)] });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
reportFlow(file, r, node, full, destination, kind, env, extra = {}) {
|
|
169
|
+
const decrypt = r.origins.some((o) => /decrypted secret copied/.test(o.detail));
|
|
170
|
+
const span = spanOf(node) || { startLine: 1, startColumn: 0 };
|
|
171
|
+
const rule = decrypt ? 'nix-secret-decrypt-copy' : kind === 'build' ? 'nix-secret-build' : kind === 'log' ? 'nix-secret-log' : 'nix-secret-store';
|
|
172
|
+
const sev = kind === 'log' ? 'medium' : decrypt ? 'high' : kind === 'build' ? 'high' : /hash/i.test(full[full.length - 1] || '') ? 'medium' : 'high';
|
|
173
|
+
const o0 = r.secret[0];
|
|
174
|
+
this.emit(rule, {
|
|
175
|
+
...this.at(file, span), file, severity: sev, exposure: kind,
|
|
176
|
+
description: `${decrypt ? 'A decrypted secret is copied into' : 'A secret-origin value reaches'} ${destination.label} (${kind === 'log' ? 'printed' : kind === 'build' ? 'derivation input' : 'world-readable store path'}). Source: ${o0 ? o0.detail : 'secret'}; value redacted.`,
|
|
177
|
+
source: { kind: decrypt ? 'decrypted-file' : 'option', detail: o0 && o0.detail, file }, destination,
|
|
178
|
+
originalLocation: span, chain: [...(o0 ? o0.chain : []), hop(file, span, `reaches ${destination.label}`)].filter(Boolean),
|
|
179
|
+
origins: r.secret, evidence: { redacted: true, via: full.filter(Boolean).join('.') }, ...extra,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── walking ───────────────────────────────────────────────────────────────
|
|
184
|
+
walkFile(file) {
|
|
185
|
+
const rec = this.ws.parse(file);
|
|
186
|
+
if (!rec || !rec.ast) { this.gaps.push({ kind: 'parse-failed', file, detail: rec ? `parse status ${rec.parse.status}` : 'unreadable' }); return; }
|
|
187
|
+
this.visit(file, rec.ast, mkEnv(null, new Map(), [], file), [], 0, false);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
visit(file, node, env, path, depth, asFn) {
|
|
191
|
+
if (!node || typeof node !== 'object' || depth > 200) return;
|
|
192
|
+
switch (node.type) {
|
|
193
|
+
case 'lambda': {
|
|
194
|
+
const vars = new Map();
|
|
195
|
+
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 });
|
|
196
|
+
else if (node.param.name) vars.set(node.param.name, { kind: 'param', name: node.param.name, span: node.span, file });
|
|
197
|
+
if (node.param.atName) vars.set(node.param.atName, { kind: 'param', name: node.param.atName, span: node.span, file });
|
|
198
|
+
this.visit(file, node.body, mkEnv(env, vars, [], file), path, depth + 1, false); return;
|
|
199
|
+
}
|
|
200
|
+
case 'let': {
|
|
201
|
+
const e2 = mkEnv(env, bindingsEnv(node, env, file), [], file);
|
|
202
|
+
for (const b of node.bindings) if (b.kind === 'attr') this.visit(file, b.value, e2, [], depth + 1, false);
|
|
203
|
+
this.visit(file, node.body, e2, path, depth + 1, false); return;
|
|
204
|
+
}
|
|
205
|
+
case 'with': { this.visit(file, node.env, env, path, depth + 1, false); this.visit(file, node.body, mkEnv(env, new Map(), [this.ev.withName(node.env)], file), path, depth + 1, false); return; }
|
|
206
|
+
case 'attrset': {
|
|
207
|
+
const e2 = node.rec ? mkEnv(env, bindingsEnv(node, env, file), [], file) : env;
|
|
208
|
+
for (const b of node.bindings) {
|
|
209
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
210
|
+
const full = [...path, ...attrKey(b.path)];
|
|
211
|
+
this.inspectBinding(file, full, b.value, b.span, e2);
|
|
212
|
+
this.visit(file, b.value, e2, full, depth + 1, false);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
case 'app': {
|
|
217
|
+
if (!asFn) this.inspectApp(file, node, env, path);
|
|
218
|
+
this.visit(file, node.fn, env, path, depth + 1, true); this.visit(file, node.arg, env, path, depth + 1, false); return;
|
|
219
|
+
}
|
|
220
|
+
case 'string': {
|
|
221
|
+
if (!node.interpolated) { for (const [label, re, sev] of TOKEN_FORMATS) if (re.test(node.literal || '')) { /* reported through its binding when it has one; bare strings: */ if (!this.stringSeen(file, node)) this.emit('nix-secret-plaintext', { ...this.at(file, node.span), file, severity: sev, exposure: 'plaintext-source', description: `A ${label} is written as a literal (value redacted).`, source: { kind: 'literal', file, line: node.span.startLine }, destination: { kind: 'source', label: 'Nix source' }, evidence: { ...redact(node.literal), format: label }, originalLocation: node.span, chain: [hop(file, node.span, `literal ${label}`)].filter(Boolean), origins: [mkOrigin('secret', `literal ${label}`)] }); } }
|
|
222
|
+
for (const part of node.parts) if (part.kind === 'interp') this.visit(file, part.expr, env, path, depth + 1, false);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
default:
|
|
226
|
+
for (const k of ['left', 'right', 'operand', 'expr', 'cond', 'then', 'else', 'body', 'base']) if (node[k] && typeof node[k] === 'object') this.visit(file, node[k], env, path, depth + 1, false);
|
|
227
|
+
if (node.items) for (const it of node.items) this.visit(file, it, env, path, depth + 1, false);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
stringSeen(file, node) { const k = `${file}:${node.start}`; if (this.scriptKeys.has(k)) return true; this.scriptKeys.add(k); return false; }
|
|
231
|
+
|
|
232
|
+
inspectBinding(file, full, value, bspan, env) {
|
|
233
|
+
const v = unparen(value);
|
|
234
|
+
const last = full[full.length - 1];
|
|
235
|
+
const dest = destOf(full);
|
|
236
|
+
const sname = secretName(last);
|
|
237
|
+
// literal credentials
|
|
238
|
+
if (v && v.type === 'string' && !v.interpolated) {
|
|
239
|
+
this.scriptKeys.add(`${file}:${v.start}`);
|
|
240
|
+
if (this.inspectLiteral(file, full, v, bspan, dest ? { kind: 'store', label: dest.label, exposure: dest.exposure } : null)) return;
|
|
241
|
+
if (dest && last === 'text') this.inspectText(file, v, full, { kind: 'store', label: dest.label, exposure: 'store' }, env);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// systemd serviceConfig.Environment = [ "K=V" ... ] / SetCredential
|
|
245
|
+
if (v && v.type === 'list' && full.includes('serviceConfig') && /^(?:Environment|SetCredential)$/.test(last)) {
|
|
246
|
+
for (const it of v.items) {
|
|
247
|
+
const s = unparen(it);
|
|
248
|
+
if (!s || s.type !== 'string') continue;
|
|
249
|
+
const dst = { kind: 'store', label: 'systemd unit file', exposure: 'store' };
|
|
250
|
+
const text = s.parts.filter((p) => p.kind === 'text').map((p) => p.value).join('');
|
|
251
|
+
const key = (/^\s*([A-Za-z_][\w.-]*)\s*[=:]/.exec(text) || [])[1];
|
|
252
|
+
if (!s.interpolated) { this.scriptKeys.add(`${file}:${s.start}`); this.inspectText(file, s, full, dst, env); continue; }
|
|
253
|
+
const r = this.secretOrigins(s, env);
|
|
254
|
+
if (r.secret.length && !this.isRuntimeRef(s, env)) this.reportFlow(file, r, s, full, dst, 'store', env, { source: { kind: 'option', detail: r.secret[0].detail, file, key } });
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// evaluate the value for secret origins
|
|
259
|
+
if (!v || v.type === 'attrset' || v.type === 'lambda' || v.type === 'list') return;
|
|
260
|
+
if (this.isRuntimeRef(v, env)) { this.safe.push({ file, line: bspan && bspan.startLine, attr: full.filter(Boolean).join('.'), reason: 'runtime secret reference' }); return; }
|
|
261
|
+
const r = this.secretOrigins(v, env);
|
|
262
|
+
const hereDest = dest ? { kind: 'store', label: dest.label, exposure: dest.exposure } : null;
|
|
263
|
+
if (r.secret.length) {
|
|
264
|
+
// a secret-named binding at a store-bound destination, or ANY decrypt-and-copy
|
|
265
|
+
const decrypt = r.origins.some((o) => /decrypted secret copied/.test(o.detail));
|
|
266
|
+
if (hereDest || decrypt) { this.reportFlow(file, r, v, full, hereDest || { kind: 'store', label: 'a value in the Nix store', exposure: 'store' }, 'store', env); return; }
|
|
267
|
+
if (sname) this.reportFlow(file, r, v, full, { kind: 'store', label: `option ${full.filter(Boolean).join('.')} (rendered into the store)`, exposure: 'store' }, 'store', env);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// text content with embedded literals under a store-bound destination
|
|
271
|
+
if (v.type === 'string' && hereDest && last === 'text') { this.scriptKeys.add(`${file}:${v.start}`); this.inspectText(file, v, full, hereDest, env); }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
inspectApp(file, node, env, path) {
|
|
275
|
+
const { fn, args } = this.ev.flatten(node);
|
|
276
|
+
const c = this.ev.calleeOf(fn);
|
|
277
|
+
const name = c.name; const full = c.full || name;
|
|
278
|
+
if (!name) return;
|
|
279
|
+
// eval-time logging
|
|
280
|
+
if (TRACE_FNS.has(name) && (c.root === 'builtins' || c.root === 'lib' || c.root === 'pkgs' || !c.root || true) && args.length) {
|
|
281
|
+
const msgArgs = name === 'warn' || name === 'trace' || name === 'traceIf' ? args.slice(0, 1) : args.slice(0, 1);
|
|
282
|
+
for (const a of msgArgs) {
|
|
283
|
+
const r = this.secretOrigins(a, env);
|
|
284
|
+
if (r.secret.length && !this.isRuntimeRef(a, env)) this.reportFlow(file, r, a, [name], { kind: 'log', label: `evaluation trace output (${name})` }, 'log', env);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// writers: the content argument becomes a store file
|
|
288
|
+
if (WRITERS.has(name) && (c.root === 'pkgs' || c.root === 'builtins' || c.root === 'writers' || c.root === 'lib' || !c.root || c.root !== null)) {
|
|
289
|
+
const idx = WRITERS.get(name);
|
|
290
|
+
const dst = { kind: 'store', label: `store file written by ${name}`, exposure: 'store' };
|
|
291
|
+
let content = null; let attrs = null;
|
|
292
|
+
if (idx !== null && args[idx]) content = args[idx];
|
|
293
|
+
if (idx === null) { const a = unparen(args[0]); if (a && a.type === 'attrset') { const hit = attrIn(a, ['text']); if (hit) content = hit.value; attrs = a; } }
|
|
294
|
+
if (name === 'runCommand' || name === 'runCommandLocal' || name === 'runCommandNoCC') { const a = unparen(args[1]); if (a && a.type === 'attrset') attrs = a; }
|
|
295
|
+
if (content) {
|
|
296
|
+
const cn = unparen(content);
|
|
297
|
+
if (cn && cn.type === 'string' && !cn.interpolated) { this.scriptKeys.add(`${file}:${cn.start}`); this.inspectText(file, cn, [name], dst, env); }
|
|
298
|
+
else if (cn && !this.isRuntimeRef(cn, env)) {
|
|
299
|
+
const r = this.secretOrigins(cn, env);
|
|
300
|
+
if (r.secret.length) this.reportFlow(file, r, cn, [name], dst, 'store', env);
|
|
301
|
+
else if (cn.type === 'string') { this.scriptKeys.add(`${file}:${cn.start}`); this.inspectText(file, cn, [name], dst, env); }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (attrs && /^runCommand/.test(name)) this.inspectDerivationAttrs(file, attrs, env, name);
|
|
305
|
+
}
|
|
306
|
+
if (DERIVATION_FNS.has(name) || DERIVATION_FNS.has(full || '')) {
|
|
307
|
+
const a = unparen(args[args.length - 1]);
|
|
308
|
+
if (a && a.type === 'attrset' && !/^runCommand/.test(name)) this.inspectDerivationAttrs(file, a, env, name);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
inspectDerivationAttrs(file, attrs, env, builder) {
|
|
313
|
+
for (const b of attrs.bindings) {
|
|
314
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
315
|
+
const key = attrKey(b.path); const last = key[key.length - 1];
|
|
316
|
+
const val = unparen(b.value);
|
|
317
|
+
if (!val || val.type === 'lambda') continue;
|
|
318
|
+
const dst = { kind: 'build', label: `derivation ${builder} attribute ${key.filter(Boolean).join('.')} (recorded in the .drv)`, exposure: 'build' };
|
|
319
|
+
if (val.type === 'string' && !val.interpolated) {
|
|
320
|
+
this.scriptKeys.add(`${file}:${val.start}`);
|
|
321
|
+
if (secretName(last) && plausibleSecret(val.literal)) this.emit('nix-secret-build', { ...this.at(file, b.span), file, severity: 'high', exposure: 'build', description: `Derivation attribute ${key.join('.')} carries a credential literal (value redacted); derivation attributes are recorded in the .drv and the build environment.`, source: { kind: 'literal', file, line: b.span.startLine, attr: key.join('.') }, destination: dst, evidence: redact(val.literal), originalLocation: b.span, chain: [hop(file, b.span, `literal ${last}`)].filter(Boolean), origins: [mkOrigin('secret', `literal ${last}`)] });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (this.isRuntimeRef(val, env)) continue;
|
|
325
|
+
if (val.type === 'attrset') continue;
|
|
326
|
+
const r = this.secretOrigins(val, env);
|
|
327
|
+
if (r.secret.length) this.reportFlow(file, r, val, key, dst, 'build', env);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Files the manager options point at: plaintext where ciphertext is required is its own finding. */
|
|
333
|
+
function inspectReferencedFiles(w, allFiles, out) {
|
|
334
|
+
const looksEncrypted = (t) => /ENC\[AES256_GCM,|-----BEGIN AGE ENCRYPTED FILE-----|^age-encryption\.org\/v1|\bsops:\s*\n/m.test(t) || /"sops"\s*:/.test(t);
|
|
335
|
+
const looksPlainCreds = (t) => /^\s*[A-Za-z_][\w.-]*(?:pass(?:word)?|secret|token|api_?key|private_?key)[\w.-]*\s*[:=]\s*\S{4,}/im.test(t) || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(t);
|
|
336
|
+
for (const file of Object.keys(w.files)) {
|
|
337
|
+
const rec = w.ws.parse(file);
|
|
338
|
+
if (!rec || !rec.ast) continue;
|
|
339
|
+
const stack = [rec.ast];
|
|
340
|
+
while (stack.length) {
|
|
341
|
+
const n = stack.pop();
|
|
342
|
+
if (!n || typeof n !== 'object') continue;
|
|
343
|
+
if (n.type === 'attrset') {
|
|
344
|
+
for (const b of n.bindings) {
|
|
345
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
346
|
+
const key = attrKey(b.path); const last = key[key.length - 1];
|
|
347
|
+
const v = unparen(b.value);
|
|
348
|
+
if (v && v.type === 'path' && !v.interpolated && (last === 'sopsFile' || last === 'file')) {
|
|
349
|
+
const rel = String(v.literal);
|
|
350
|
+
const target = rel.startsWith('.') ? (file.includes('/') ? file.replace(/\/[^/]*$/, '/') : '') + rel.replace(/^\.\//, '') : rel;
|
|
351
|
+
const text = allFiles[target.replace(/\/\.\//g, '/')];
|
|
352
|
+
if (typeof text === 'string' && !looksEncrypted(text) && looksPlainCreds(text)) {
|
|
353
|
+
out.push({ file, bspan: b.span, target, last });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const k of Object.keys(n)) { const c = n[k]; if (c && typeof c === 'object') { if (Array.isArray(c)) stack.push(...c); else stack.push(c); } }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {{files: Record<string,string>}} opts every file handed in (Nix sources and referenced data files)
|
|
365
|
+
*/
|
|
366
|
+
export function analyzeNixSecrets(opts = {}) {
|
|
367
|
+
const all = opts.files || {};
|
|
368
|
+
const nix = {};
|
|
369
|
+
for (const [p, t] of Object.entries(all)) if (typeof t === 'string' && /\.nix$/i.test(p) && !isLanguageExcludedPath(p)) nix[p] = t;
|
|
370
|
+
const w = new Walker(nix);
|
|
371
|
+
for (const f of Object.keys(nix).sort()) w.walkFile(f);
|
|
372
|
+
|
|
373
|
+
// secrets interpolated into generated scripts (NIX-003 flows), distinct from injection
|
|
374
|
+
const scripts = analyzeNixScripts({ files: nix });
|
|
375
|
+
for (const fl of scripts.flows) {
|
|
376
|
+
const secret = (fl.originDetails || []).filter((o) => o.kind === 'secret' && !/^option config\.(?:sops|age|agenix)\./.test(o.detail));
|
|
377
|
+
if (!secret.length) continue;
|
|
378
|
+
const cmd = fl.sink && fl.sink.command;
|
|
379
|
+
const printing = /^(?:echo|printf|logger|tee|cat|print|wall|notify-send|systemd-cat)$/.test(cmd || '') || fl.scriptHasTrace || (fl.sink && fl.sink.kind === 'argument' && cmd === 'curl' && false);
|
|
380
|
+
const sp = fl.originalLocation;
|
|
381
|
+
const origins = secret.map((o) => mkOrigin('secret', o.detail, fl.chain || []));
|
|
382
|
+
const dst = { kind: 'store', label: `generated script ${fl.attrPath} (a store file)`, exposure: 'store' };
|
|
383
|
+
const decrypt = secret.some((o) => /decrypted secret copied/.test(o.detail));
|
|
384
|
+
w.emit(decrypt ? 'nix-secret-decrypt-copy' : 'nix-secret-store', { ...w.at(fl.file, sp), file: fl.file, severity: 'high', exposure: 'store', description: `A secret-origin value is interpolated into the generated script ${fl.attrPath}, which becomes a world-readable store file (value redacted). Source: ${secret[0].detail}.`, source: { kind: 'option', detail: secret[0].detail, file: fl.file }, destination: dst, originalLocation: sp, generatedLocation: fl.generatedLocation, chain: fl.chain, origins, evidence: { redacted: true, via: fl.attrPath } });
|
|
385
|
+
if (printing) w.emit('nix-secret-log', { ...w.at(fl.file, sp), file: fl.file, severity: 'medium', exposure: 'log', description: `The same secret is ${cmd ? `printed by ${cmd}` : 'echoed by a traced script'} in ${fl.attrPath} (value redacted).`, source: { kind: 'option', detail: secret[0].detail, file: fl.file }, destination: { kind: 'log', label: `script output of ${fl.attrPath} (journal / build log)` }, originalLocation: sp, generatedLocation: fl.generatedLocation, chain: fl.chain, origins, evidence: { redacted: true, via: fl.attrPath } });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const plain = [];
|
|
389
|
+
inspectReferencedFiles(w, all, plain);
|
|
390
|
+
for (const p of plain) w.emit('nix-secret-plaintext-file', { ...w.at(p.file, p.bspan), file: p.file, severity: 'high', exposure: 'plaintext-source', description: `${p.last} points at ${p.target}, which holds credentials but has no sops/age encryption markers (content not shown).`, source: { kind: 'file', file: p.target }, destination: { kind: 'source', label: 'repository and every clone' }, originalLocation: p.bspan, chain: [hop(p.file, p.bspan, `${p.last} = ${p.target}`)].filter(Boolean), origins: [mkOrigin('secret', `plaintext file ${p.target}`)], evidence: { redacted: true } });
|
|
391
|
+
|
|
392
|
+
const findings = w.findings.sort((a, b) => (SEV[b.severity] - SEV[a.severity]) || a.file.localeCompare(b.file) || a.line - b.line);
|
|
393
|
+
return { version: NIX_SECRETS_VERSION, findings, safe: w.safe, gaps: [...w.gaps, ...scripts.gaps], analyzed: Object.keys(nix).length };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export { SAFE_KINDS };
|