@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/bin/agentic-security.js +131 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1192 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +20 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +174 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +289 -0
- package/src/language/trusted-inputs.js +38 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Verified Nix configuration and supply-chain fixes (NIX-010).
|
|
2
|
+
//
|
|
3
|
+
// Scoped, AST-aware edits to the ORIGINAL Nix source, validated by the shared remediation lifecycle
|
|
4
|
+
// (fix-lifecycle.js): syntax, a rescan in which the finding is gone and nothing medium-or-higher is new,
|
|
5
|
+
// preview, backup, undo. Deterministic edits exist only where they are provably the same program minus
|
|
6
|
+
// the weakness:
|
|
7
|
+
//
|
|
8
|
+
// shell escaping lib.escapeShellArg around an unquoted interpolation, or removing the quotes that
|
|
9
|
+
// defeat an escapeShellArg already present
|
|
10
|
+
// option hardening the winning definition of an SSH / firewall / cache-signature / sandbox option is
|
|
11
|
+
// set to its safe literal (effective-option precedence is honoured: an edit to a
|
|
12
|
+
// definition that another module overrides leaves the finding, so it is NOT fixed)
|
|
13
|
+
// transport http:// -> https:// in a cache URL
|
|
14
|
+
// flake input upgrade only the input's url in flake.nix; flake.lock is never fabricated
|
|
15
|
+
//
|
|
16
|
+
// Everything else becomes a reviewable proposal with its completeness tier: `manual` (a human step is
|
|
17
|
+
// required: secret migration, a hash that needs a fetch), `blocked` (a precondition is unmet), or
|
|
18
|
+
// `source-edit-requires-relock`. Nothing here activates or deploys a configuration, runs nix, or contacts a
|
|
19
|
+
// network: this module imports no process spawner.
|
|
20
|
+
|
|
21
|
+
import { parseNix } from './nix-parser.js';
|
|
22
|
+
import { buildNixIR } from './nix-ir.js';
|
|
23
|
+
import { analyzeNixScripts } from './nix-script-taint.js';
|
|
24
|
+
import { analyzeNixSecrets } from './nix-secrets.js';
|
|
25
|
+
import { analyzeNixBuildTrust } from './nix-build-trust.js';
|
|
26
|
+
import { analyzeNixosHardening } from './nixos-hardening.js';
|
|
27
|
+
import { runFixLifecycle, unifiedDiff, writeWithBackup, undoFix } from './fix-lifecycle.js';
|
|
28
|
+
import { parseFlakeLock } from './nix-inventory.js';
|
|
29
|
+
|
|
30
|
+
export const NIX_FIX_VERSION = 'nix-fix/1';
|
|
31
|
+
export const TIERS = Object.freeze({ edit: 'full-source-edit', relock: 'source-edit-requires-relock', guidance: 'guidance-only', blocked: 'blocked' });
|
|
32
|
+
export { unifiedDiff, writeWithBackup, undoFix };
|
|
33
|
+
|
|
34
|
+
const splice = (t, a, b, r) => t.slice(0, a) + r + t.slice(b);
|
|
35
|
+
const SIMPLE = /^[A-Za-z_][\w'-]*(?:\.[A-Za-z_][\w'-]*)*$/;
|
|
36
|
+
|
|
37
|
+
// ── option fixes ─────────────────────────────────────────────────────────────
|
|
38
|
+
const OPTION_FIXES = {
|
|
39
|
+
'ssh-root-login': { to: '"no"', kind: 'string', consequences: ['Root can no longer log in over SSH. Make sure a non-root account with an authorized key and sudo access exists BEFORE deploying, or you can lock yourself out.'] },
|
|
40
|
+
'ssh-password-auth': { to: 'false', kind: 'bool', consequences: ['Password logins stop working for every account. Each administrator needs an authorized SSH key first (users.users.<name>.openssh.authorizedKeys).'] },
|
|
41
|
+
'ssh-empty-passwords': { to: 'false', kind: 'bool', consequences: ['Accounts with an empty password can no longer log in over SSH.'] },
|
|
42
|
+
'firewall-disabled': { to: 'true', kind: 'bool', consequences: ['Every port not listed in networking.firewall.allowedTCPPorts/allowedUDPPorts becomes unreachable from the network. Open the ports your services need (and services.openssh.openFirewall for SSH) or you can lose remote access.'] },
|
|
43
|
+
'nix-require-sigs-disabled': { to: 'true', kind: 'bool', consequences: ['Store paths copied or substituted without a trusted signature are rejected. Add the signing keys of your caches to nix.settings.trusted-public-keys first.'] },
|
|
44
|
+
'nix-sandbox-disabled': { to: 'true', kind: 'bool', consequences: ['Builds that relied on host paths or network access will fail until they are made hermetic.'] },
|
|
45
|
+
'nix-accept-flake-config': { to: 'false', kind: 'bool', consequences: ['nixConfig from flakes is no longer applied automatically: settings a flake asked for (extra substituters, keys) must be accepted explicitly.'] },
|
|
46
|
+
};
|
|
47
|
+
const TRANSPORT_RULES = new Set(['nix-substituter-insecure-transport', 'nix-fetch-insecure-transport']);
|
|
48
|
+
const MANUAL = {
|
|
49
|
+
'nix-secret-store': ['Move the value out of the Nix expression: reference a runtime file (a *File option, EnvironmentFile or LoadCredential) fed by sops-nix or agenix, so the secret never becomes part of a store path.', 'Re-encrypt the secret with sops or age and commit only the ciphertext.', 'Rotate the credential: it has already been readable in the store.'],
|
|
50
|
+
'nix-secret-build': ['Do not pass the secret into the derivation: read it at runtime instead.', 'Rotate the credential: derivation attributes are recorded in the .drv.'],
|
|
51
|
+
'nix-secret-decrypt-copy': ['Hand the runtime path (config.sops.secrets.<name>.path) to the service instead of reading its content during evaluation.', 'Rotate the credential: its decrypted content was copied into the store.'],
|
|
52
|
+
'nix-secret-plaintext': ['Replace the literal with a runtime file reference and encrypt the secret with sops or age.', 'Rotate the credential and purge it from history (it is in every clone).'],
|
|
53
|
+
'nix-secret-log': ['Stop printing the value; log a fingerprint or a reference.'],
|
|
54
|
+
'nix-secret-plaintext-file': ['Encrypt the file with sops or age before committing it; the manager option only protects an encrypted file.'],
|
|
55
|
+
};
|
|
56
|
+
const HASH_MANUAL = new Set(['nix-fetch-missing-hash', 'nix-fetch-fake-hash', 'nix-fetch-floating-rev']);
|
|
57
|
+
|
|
58
|
+
function planOptionFix(finding, files, fx, source) {
|
|
59
|
+
// `optionEvidence` is the option-level evidence a scan carries; `evidence` is that same array on a finding that comes
|
|
60
|
+
// straight from the hardening analysis (the scan's own `evidence` field is the list of detectors that agreed).
|
|
61
|
+
const evList = Array.isArray(finding.optionEvidence) ? finding.optionEvidence : (Array.isArray(finding.evidence) ? finding.evidence.filter((e) => e && typeof e === 'object') : []);
|
|
62
|
+
const evidence = evList[0] || null;
|
|
63
|
+
const option = (evidence && evidence.option) || finding.subject;
|
|
64
|
+
const winner = evidence && (evidence.sources || []).find((s) => s.role === 'winner');
|
|
65
|
+
const file = source ? source.file : (winner ? winner.file : finding.file);
|
|
66
|
+
const line = source ? source.line : (winner ? winner.line : finding.line);
|
|
67
|
+
const text = files[file];
|
|
68
|
+
if (typeof text !== 'string') return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: `the defining file ${file} is not in the supplied tree` };
|
|
69
|
+
const parse = parseNix(text, { file });
|
|
70
|
+
if (!parse.ast) return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: `${file} does not parse` };
|
|
71
|
+
const ir = buildNixIR(parse, { file, source: text });
|
|
72
|
+
const cands = ir.bindings.filter((b) => b.pathText === option && b.valueSpan);
|
|
73
|
+
const hit = cands.find((b) => b.span && Math.abs(b.span.startLine - line) <= 0) || cands.find((b) => b.span && b.span.startLine <= line && b.span.endLine >= line) || (cands.length === 1 ? cands[0] : null);
|
|
74
|
+
if (!hit) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: `no literal definition of ${option} was found at ${file}:${line}: it may be set by a function, an import or an attribute-set merge`, proposal: { option, set: fx.to } };
|
|
75
|
+
const vs = hit.valueSpan;
|
|
76
|
+
const current = text.slice(vs.startOffset, vs.endOffset);
|
|
77
|
+
const re = fx.kind === 'bool' ? /\b(?:true|false)\b/g : /"(?:[^"\\]|\\.)*"/g;
|
|
78
|
+
const lits = current.match(re) || [];
|
|
79
|
+
if (lits.length !== 1) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: `the definition of ${option} is not a single literal (${current.slice(0, 60)}): the edit would have to choose a branch`, proposal: { option, set: fx.to } };
|
|
80
|
+
const next = current.replace(re, fx.to);
|
|
81
|
+
return { ok: true, file, before: text, after: splice(text, vs.startOffset, vs.endOffset, next), ruleId: `nix-set-${option}`, label: 'FULL', explanation: `${option} = ${current.trim()} -> ${next.trim()} at ${file}:${hit.span.startLine}, the definition that wins by priority.`, consequences: fx.consequences, tier: TIERS.edit, behavior: { kind: 'option', option, from: current.trim(), to: next.trim() } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function planTransportFix(finding, files) {
|
|
85
|
+
const file = finding.file; const text = files[file];
|
|
86
|
+
if (typeof text !== 'string') return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: `${file} is not in the supplied tree` };
|
|
87
|
+
const lines = text.split('\n'); const ln = (finding.line || 1) - 1;
|
|
88
|
+
const parse = parseNix(text, { file });
|
|
89
|
+
if (!parse.ast) return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: `${file} does not parse` };
|
|
90
|
+
const lineText = lines[ln] || '';
|
|
91
|
+
if (!/"http:\/\//.test(lineText) && !/'http:\/\//.test(lineText)) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: 'the insecure URL is not a literal on the reported line' };
|
|
92
|
+
let off = 0; for (let i = 0; i < ln; i++) off += lines[i].length + 1;
|
|
93
|
+
const next = lineText.replace(/(["'])http:\/\//g, '$1https://');
|
|
94
|
+
return { ok: true, file, before: text, after: splice(text, off, off + lineText.length, next), ruleId: 'nix-https-transport', label: 'MITIGATION', explanation: `Upgraded the cache URL on line ${finding.line} from http:// to https://.`, consequences: ['The cache must serve TLS on the same host; if it does not, substitution from it will fail (builds fall back to other substituters or to building locally).'], tier: TIERS.edit, behavior: { kind: 'transport' } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── shell escaping ───────────────────────────────────────────────────────────
|
|
98
|
+
function topFormals(ast) {
|
|
99
|
+
let n = ast; let node = null;
|
|
100
|
+
while (n && (n.type === 'lambda' || n.type === 'paren')) { if (n.type === 'paren') { n = n.expr; continue; } node = n; n = n.body; break; }
|
|
101
|
+
const names = new Set(); let pattern = null;
|
|
102
|
+
let cur = ast;
|
|
103
|
+
while (cur && (cur.type === 'lambda' || cur.type === 'paren')) {
|
|
104
|
+
if (cur.type === 'paren') { cur = cur.expr; continue; }
|
|
105
|
+
if (cur.param.kind === 'pattern') { for (const f of cur.param.formals) names.add(f.name); if (!pattern) pattern = cur.param; } else if (cur.param.name) names.add(cur.param.name);
|
|
106
|
+
cur = cur.body;
|
|
107
|
+
}
|
|
108
|
+
return { names, pattern, node };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function planShellFix(finding, files) {
|
|
112
|
+
const file = finding.file; const text = files[file];
|
|
113
|
+
const loc = finding.originalLocation;
|
|
114
|
+
if (typeof text !== 'string' || !loc || !Number.isInteger(loc.startOffset)) return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: 'the finding carries no exact source location' };
|
|
115
|
+
const parse = parseNix(text, { file });
|
|
116
|
+
if (!parse.ast) return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: `${file} does not parse` };
|
|
117
|
+
const open = text.lastIndexOf('${', loc.startOffset);
|
|
118
|
+
let close = text.indexOf('}', loc.endOffset);
|
|
119
|
+
if (open < 0 || close < 0 || text.slice(open + 2, loc.startOffset).trim() !== '' || text.slice(loc.endOffset, close).trim() !== '') return { ok: false, status: 'manual', tier: TIERS.guidance, reason: 'could not delimit the interpolation exactly' };
|
|
120
|
+
const expr = text.slice(loc.startOffset, loc.endOffset);
|
|
121
|
+
const before = text[open - 1]; const after = text[close + 1];
|
|
122
|
+
const quoted = (before === '"' || before === "'") && before === after;
|
|
123
|
+
const wordBoundary = (c) => c === undefined || /[\s;|&()]/.test(c);
|
|
124
|
+
const isWholeWord = quoted && wordBoundary(text[open - 2]) && wordBoundary(text[close + 2]);
|
|
125
|
+
const ctx = finding.sink && finding.sink.context;
|
|
126
|
+
const { names, pattern } = topFormals(parse.ast);
|
|
127
|
+
let fn = null; let addLib = false;
|
|
128
|
+
if (names.has('lib')) fn = 'lib.escapeShellArg';
|
|
129
|
+
else if (pattern && pattern.ellipsis) { fn = 'lib.escapeShellArg'; addLib = true; }
|
|
130
|
+
else if (names.has('pkgs')) fn = 'pkgs.lib.escapeShellArg';
|
|
131
|
+
else return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: 'lib is not in scope and the function takes a closed parameter list: adding it changes the interface of this file (manual step)', proposal: { wrap: 'lib.escapeShellArg' } };
|
|
132
|
+
let rangeStart = open; let rangeEnd = close + 1; let replacement;
|
|
133
|
+
if (finding.rule === 'nix-escape-wrong-context') {
|
|
134
|
+
if (!isWholeWord) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: 'the escaped value shares its quotes with other text: removing them needs a human decision', proposal: { removeQuotes: true } };
|
|
135
|
+
rangeStart = open - 1; rangeEnd = close + 2; replacement = text.slice(open, close + 1);
|
|
136
|
+
} else if (finding.rule === 'nix-shell-injection') {
|
|
137
|
+
const wrapped = SIMPLE.test(expr.trim()) ? `${fn} ${expr.trim()}` : `${fn} (${expr.trim()})`;
|
|
138
|
+
if (ctx === 'double' || ctx === 'single') {
|
|
139
|
+
if (!isWholeWord) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: `the value is inside ${ctx} quotes together with other text: it cannot be escaped without restructuring the string`, proposal: { wrap: fn } };
|
|
140
|
+
rangeStart = open - 1; rangeEnd = close + 2;
|
|
141
|
+
} else if (ctx !== 'unquoted') return { ok: false, status: 'manual', tier: TIERS.guidance, reason: `the shell context "${ctx}" has no deterministic escape` };
|
|
142
|
+
replacement = `\${${wrapped}}`;
|
|
143
|
+
} else return { ok: false, status: 'unsupported', tier: TIERS.guidance, reason: `rule ${finding.rule} has no shell fixer` };
|
|
144
|
+
let next = splice(text, rangeStart, rangeEnd, replacement);
|
|
145
|
+
if (addLib) {
|
|
146
|
+
// the formal list is earlier in the file, so its offsets are unaffected by the edit above
|
|
147
|
+
const ps = pattern.span; const head = text.slice(ps.startOffset, ps.endOffset);
|
|
148
|
+
next = splice(next, ps.startOffset, ps.endOffset, head.replace(/^\{\s*/, (m) => `${m}lib, `));
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, file, before: text, after: next, ruleId: finding.rule === 'nix-escape-wrong-context' ? 'nix-unquote-escaped' : 'nix-escape-shell-arg', label: 'FULL', tier: TIERS.edit, explanation: finding.rule === 'nix-escape-wrong-context' ? 'Removed the quotes around an already escaped value: lib.escapeShellArg supplies its own.' : `Wrapped the interpolation in ${fn}${isWholeWord ? ' and dropped the surrounding quotes' : ''}.`, consequences: addLib ? ['`lib` was added to this file\'s function arguments (it is already provided to NixOS modules).'] : [], behavior: { kind: 'shell-escape', fn, quotesRemoved: isWholeWord && (ctx === 'double' || ctx === 'single' || finding.rule === 'nix-escape-wrong-context') } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function planEnvQuoteFix(finding, files) {
|
|
154
|
+
const file = finding.file; const text = files[file]; const loc = finding.originalLocation;
|
|
155
|
+
if (typeof text !== 'string' || !loc || !Number.isInteger(loc.startOffset)) return { ok: false, status: 'blocked', tier: TIERS.blocked, reason: 'the finding carries no exact source location' };
|
|
156
|
+
const seg = text.slice(loc.startOffset, loc.endOffset);
|
|
157
|
+
if (!/^\$(?:\{[A-Za-z_]\w*\}|[A-Za-z_]\w*)$/.test(seg)) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: 'the expansion is not a plain $NAME or ${NAME}' };
|
|
158
|
+
const prev = text[loc.startOffset - 1]; const next = text[loc.endOffset];
|
|
159
|
+
if (prev === '"' || prev === "'") return { ok: false, status: 'manual', tier: TIERS.guidance, reason: 'the expansion is already inside quotes' };
|
|
160
|
+
const quoted = `"${seg}"`;
|
|
161
|
+
return { ok: true, file, before: text, after: splice(text, loc.startOffset, loc.endOffset, quoted), ruleId: 'nix-quote-expansion', label: 'MITIGATION', tier: TIERS.edit, explanation: `Quoted the shell expansion ${seg}: it is no longer word-split or globbed. A value that starts with "-" can still be read as an option by the command.`, consequences: [], behavior: { kind: 'quote-expansion', variable: seg, adjacentText: next } };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── planning ─────────────────────────────────────────────────────────────────
|
|
165
|
+
/**
|
|
166
|
+
* @returns {{ok:true,...}|{ok:false,status:'blocked'|'manual'|'unsupported',tier:string,reason:string,proposal?:object}}
|
|
167
|
+
*/
|
|
168
|
+
export function planNixFix(finding, files, opts = {}) {
|
|
169
|
+
if (!finding || !finding.rule) return { ok: false, status: 'unsupported', tier: TIERS.guidance, reason: 'the finding names no rule' };
|
|
170
|
+
const r = finding.rule;
|
|
171
|
+
if (r === 'nix-shell-injection' || r === 'nix-escape-wrong-context') return planShellFix(finding, files);
|
|
172
|
+
if (r === 'nix-service-env-shell') return planEnvQuoteFix(finding, files);
|
|
173
|
+
if (OPTION_FIXES[r]) return planOptionFix(finding, files, OPTION_FIXES[r], opts.source || null);
|
|
174
|
+
if (TRANSPORT_RULES.has(r)) return planTransportFix(finding, files);
|
|
175
|
+
if (MANUAL[r]) return { ok: false, status: 'manual', tier: TIERS.guidance, reason: 'moving a secret out of the store changes where the credential lives and who can read it: it needs a human migration, never an automatic rewrite', proposal: { steps: MANUAL[r], rotate: r !== 'nix-secret-log', manager: 'sops-nix or agenix' } };
|
|
176
|
+
if (HASH_MANUAL.has(r)) return { ok: false, status: 'manual', tier: TIERS.blocked, reason: 'the correct hash or revision can only be obtained by fetching the source, which a scan never does; a placeholder hash would be a fabricated value', proposal: { steps: ['Run `nix-prefetch-url` / `nix store prefetch-file` (or build once with lib.fakeHash and read the reported hash) from a trusted machine.', 'Pin a full revision, not a branch.', 'Paste the real hash.'], neverDo: 'insert a made-up or all-zero hash' } };
|
|
177
|
+
return { ok: false, status: 'unsupported', tier: TIERS.guidance, reason: `no deterministic fix exists for ${r}` };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── rescan ───────────────────────────────────────────────────────────────────
|
|
181
|
+
/** The Nix analyses over an in-memory tree: the same rule sets a scan runs, nothing evaluated. */
|
|
182
|
+
export async function rescanNix(files) {
|
|
183
|
+
const nix = {};
|
|
184
|
+
for (const [p, t] of Object.entries(files)) if (typeof t === 'string' && (/\.nix$/i.test(p) || /(^|\/)flake\.lock$/.test(p))) nix[p] = t;
|
|
185
|
+
const entry = nix['configuration.nix'] !== undefined ? 'configuration.nix' : undefined;
|
|
186
|
+
const out = [];
|
|
187
|
+
const nixOnly = Object.fromEntries(Object.entries(nix).filter(([p]) => /\.nix$/.test(p)));
|
|
188
|
+
out.push(...analyzeNixScripts({ files: nixOnly }).findings);
|
|
189
|
+
out.push(...analyzeNixSecrets({ files }).findings);
|
|
190
|
+
try { out.push(...analyzeNixBuildTrust({ files: nix, ...(entry ? { entry } : {}) }).findings); } catch { /* reported as a gap by the scan; the fix gate treats absence as no finding */ }
|
|
191
|
+
if (entry) { try { out.push(...analyzeNixosHardening({ entry, files: nixOnly }).findings); } catch { /* same */ } }
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
const matchKey = (f) => `${f.rule || f.cwe}|${f.subject || f.attrPath || ''}|${f.file}`;
|
|
195
|
+
const syntaxGate = (plan) => {
|
|
196
|
+
const a = parseNix(plan.before, { file: plan.file }); const b = parseNix(plan.after, { file: plan.file });
|
|
197
|
+
const na = (a.errors || []).length, nb = (b.errors || []).length;
|
|
198
|
+
return { ok: !!b.ast && nb <= na, errorsBefore: na, errorsAfter: nb, detail: nb <= na ? 'no new syntax errors' : `${nb - na} new syntax error(s)` };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Validate (and optionally apply) a fix through the shared lifecycle. */
|
|
202
|
+
export async function validateNixFix(finding, o) {
|
|
203
|
+
const plan = planNixFix(finding, o.files, o);
|
|
204
|
+
if (!plan.ok) return { status: plan.status || 'unsupported', applied: false, tier: plan.tier, reason: plan.reason, proposal: plan.proposal || null, ...plan };
|
|
205
|
+
const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
|
|
206
|
+
return { ...res, consequences: plan.consequences || [], tier: res.status === 'blocked' ? TIERS.blocked : plan.tier, explanation: plan.explanation };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Verify a patch SUPPLIED BY SOMEONE ELSE through the same gates as a deterministic Nix fix. Nothing is written. */
|
|
210
|
+
export async function verifyNixProposal(finding, { files, file, proposal, rescan }) {
|
|
211
|
+
const before = files[file];
|
|
212
|
+
const plan = { file, before: typeof before === 'string' ? before : '', after: proposal, label: 'WORKAROUND', tier: TIERS.edit };
|
|
213
|
+
return runFixLifecycle({ plan, files, finding, matchKey, rescan: rescan || rescanNix, syntax: syntaxGate, apply: false });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── flake input upgrade ──────────────────────────────────────────────────────
|
|
217
|
+
/** Compare two lock texts: only the named input's node (and nodes only it reaches) may differ. */
|
|
218
|
+
export function lockDiff(beforeText, afterText, inputName) {
|
|
219
|
+
const a = parseFlakeLock(beforeText); const b = parseFlakeLock(afterText);
|
|
220
|
+
if (a.status !== 'ok' || b.status !== 'ok') return { ok: false, reason: `a lock does not parse (${a.error || b.error || a.status})`, changed: [] };
|
|
221
|
+
const A = JSON.parse(beforeText).nodes; const B = JSON.parse(afterText).nodes;
|
|
222
|
+
const root = (a.root);
|
|
223
|
+
const target = (A[root].inputs || {})[inputName];
|
|
224
|
+
const targetKey = typeof target === 'string' ? target : null;
|
|
225
|
+
// nodes reachable ONLY through the target input
|
|
226
|
+
const reach = (nodes, from) => { const seen = new Set(); const q = [from]; while (q.length) { const k = q.pop(); if (!k || seen.has(k) || !nodes[k]) continue; seen.add(k); for (const v of Object.values(nodes[k].inputs || {})) if (typeof v === 'string') q.push(v); } return seen; };
|
|
227
|
+
const allowed = new Set();
|
|
228
|
+
if (targetKey) { const rest = new Set(); for (const [n, k] of Object.entries(A[root].inputs || {})) if (n !== inputName && typeof k === 'string') for (const x of reach(A, k)) rest.add(x); for (const x of reach(A, targetKey)) if (!rest.has(x)) allowed.add(x); }
|
|
229
|
+
const changed = [];
|
|
230
|
+
for (const k of new Set([...Object.keys(A), ...Object.keys(B)])) {
|
|
231
|
+
if (JSON.stringify(A[k]) !== JSON.stringify(B[k]) && !allowed.has(k) && !(B[k] && allowed.has(k))) changed.push(k);
|
|
232
|
+
}
|
|
233
|
+
const rootChanged = JSON.stringify(Object.fromEntries(Object.entries(A[root].inputs || {}).filter(([n]) => n !== inputName))) !== JSON.stringify(Object.fromEntries(Object.entries(B[root].inputs || {}).filter(([n]) => n !== inputName)));
|
|
234
|
+
return { ok: changed.length === 0 && !rootChanged, changed, rootChanged, reason: changed.length ? `unrelated lock node(s) changed: ${changed.join(', ')}` : (rootChanged ? 'the root inputs of other inputs changed' : '') };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Plan a targeted flake input upgrade. Edits flake.nix only. flake.lock is never fabricated: without a
|
|
239
|
+
* relocked lock supplied by the caller the result is `relock-required`, and a supplied lock is accepted only
|
|
240
|
+
* if nothing outside the upgraded input changed.
|
|
241
|
+
*/
|
|
242
|
+
export function planNixUpgrade({ files, flake = 'flake.nix', lock = 'flake.lock', input, toRef, relock = null } = {}) {
|
|
243
|
+
const text = files[flake];
|
|
244
|
+
const blocked = (reason, extra = {}) => ({ ok: false, status: 'blocked', tier: TIERS.blocked, reason, ...extra });
|
|
245
|
+
if (typeof text !== 'string') return blocked(`${flake} is not in the supplied tree`);
|
|
246
|
+
if (!input || !toRef || !/^[\w.\-/]+$/.test(toRef)) return blocked('an input name and a plain target ref are required');
|
|
247
|
+
const parse = parseNix(text, { file: flake });
|
|
248
|
+
if (!parse.ast) return blocked(`${flake} does not parse`);
|
|
249
|
+
const ir = buildNixIR(parse, { file: flake, source: text });
|
|
250
|
+
const b = ir.bindings.find((x) => x.pathText === `inputs.${input}.url` && x.valueSpan);
|
|
251
|
+
if (!b) return blocked(`no literal url for input "${input}" in ${flake}`);
|
|
252
|
+
const vs = b.valueSpan; const cur = text.slice(vs.startOffset, vs.endOffset);
|
|
253
|
+
const m = /^"(github|gitlab|sourcehut):([^/"]+)\/([^/"?]+)(?:\/([^"?]+))?(\?[^"]*)?"$/.exec(cur);
|
|
254
|
+
if (!m) return blocked(`the url ${cur.slice(0, 60)} is not a plain github:/gitlab:/sourcehut: reference this planner can rewrite`);
|
|
255
|
+
const next = `"${m[1]}:${m[2]}/${m[3]}/${toRef}${m[5] || ''}"`;
|
|
256
|
+
const edits = [{ file: flake, before: text, after: splice(text, vs.startOffset, vs.endOffset, next) }];
|
|
257
|
+
const base = { ok: true, input, from: m[4] || '(default branch)', to: toRef, edits, preview: unifiedDiff(flake, text, edits[0].after), lockTouched: false, consequences: [`Input ${input} will track ${toRef}: its content, and everything that follows it, changes at the next lock update.`] };
|
|
258
|
+
const lockText = files[lock];
|
|
259
|
+
if (!relock) return { ...base, status: 'relock-required', tier: TIERS.relock, note: `${lock} was NOT modified and no hash was invented: run \`nix flake update ${input}\` (an isolated resolution, or by hand) and re-scan`, instructions: [`nix flake update ${input}`] };
|
|
260
|
+
let relocked;
|
|
261
|
+
try { relocked = relock({ ...files, [flake]: edits[0].after }); } catch (e) { return { ...base, status: 'relock-required', tier: TIERS.relock, note: `the relock step failed (${e.message}); ${lock} is unchanged` }; }
|
|
262
|
+
if (!relocked || typeof relocked.lockText !== 'string') return { ...base, status: 'relock-required', tier: TIERS.relock, note: `the relock step produced no lock; ${lock} is unchanged` };
|
|
263
|
+
if (typeof lockText !== 'string') return { ...base, status: 'blocked', tier: TIERS.blocked, ok: false, reason: `there is no ${lock} to compare against` };
|
|
264
|
+
const d = lockDiff(lockText, relocked.lockText, input);
|
|
265
|
+
if (!d.ok) return { ...base, ok: false, status: 'blocked', tier: TIERS.blocked, reason: `the relocked ${lock} is rejected: ${d.reason}`, lockDiff: d };
|
|
266
|
+
edits.push({ file: lock, before: lockText, after: relocked.lockText });
|
|
267
|
+
return { ...base, status: 'verified-relock', tier: TIERS.edit, lockTouched: true, lockDiff: d, edits, preview: edits.map((e) => unifiedDiff(e.file, e.before, e.after)).join('\n') };
|
|
268
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Shipped Nix lexical and expression grammar data (NIX-001).
|
|
2
|
+
//
|
|
3
|
+
// This is the grammar asset nix-parser.js loads. It is plain data inside the
|
|
4
|
+
// package, so a default offline install parses Nix with no `nix` binary, no
|
|
5
|
+
// network and no optional native dependency.
|
|
6
|
+
//
|
|
7
|
+
// Provenance. The tables are original work, written from the public Nix
|
|
8
|
+
// language reference (lexical syntax, operator precedence table, string and
|
|
9
|
+
// indented-string escape rules). No third-party grammar source or generated
|
|
10
|
+
// parser table is copied in. The pinned checksum covers the canonical JSON of
|
|
11
|
+
// GRAMMAR_DATA: any edit, truncation or substitution makes loadNixGrammar
|
|
12
|
+
// return a `corrupt-grammar` gap instead of parsing with a different language
|
|
13
|
+
// definition.
|
|
14
|
+
|
|
15
|
+
import * as crypto from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export const GRAMMAR_DATA = Object.freeze({
|
|
18
|
+
name: 'nix-lexical-expression',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
languageReference: 'Nix language reference (expression syntax)',
|
|
21
|
+
license: 'PolyForm-Internal-Use-1.0.0',
|
|
22
|
+
origin: 'original; derived from the public Nix language reference, no third-party grammar code',
|
|
23
|
+
keywords: Object.freeze(['if', 'then', 'else', 'assert', 'with', 'let', 'in', 'rec', 'inherit']),
|
|
24
|
+
// Binary operators by binding strength (higher binds tighter) and associativity.
|
|
25
|
+
binaryOps: Object.freeze({
|
|
26
|
+
'++': Object.freeze({ prec: 12, assoc: 'right' }),
|
|
27
|
+
'*': Object.freeze({ prec: 11, assoc: 'left' }),
|
|
28
|
+
'/': Object.freeze({ prec: 11, assoc: 'left' }),
|
|
29
|
+
'+': Object.freeze({ prec: 10, assoc: 'left' }),
|
|
30
|
+
'-': Object.freeze({ prec: 10, assoc: 'left' }),
|
|
31
|
+
'//': Object.freeze({ prec: 8, assoc: 'right' }),
|
|
32
|
+
'<': Object.freeze({ prec: 7, assoc: 'none' }),
|
|
33
|
+
'<=': Object.freeze({ prec: 7, assoc: 'none' }),
|
|
34
|
+
'>': Object.freeze({ prec: 7, assoc: 'none' }),
|
|
35
|
+
'>=': Object.freeze({ prec: 7, assoc: 'none' }),
|
|
36
|
+
'==': Object.freeze({ prec: 6, assoc: 'none' }),
|
|
37
|
+
'!=': Object.freeze({ prec: 6, assoc: 'none' }),
|
|
38
|
+
'&&': Object.freeze({ prec: 5, assoc: 'left' }),
|
|
39
|
+
'||': Object.freeze({ prec: 4, assoc: 'left' }),
|
|
40
|
+
'->': Object.freeze({ prec: 3, assoc: 'right' }),
|
|
41
|
+
}),
|
|
42
|
+
// Binding strength of the unary and postfix forms between the binary levels.
|
|
43
|
+
precedence: Object.freeze({ negate: 14, hasAttr: 13, not: 9 }),
|
|
44
|
+
punctuation: Object.freeze([
|
|
45
|
+
'...', '//', '++', '->', '==', '!=', '<=', '>=', '&&', '||', '${',
|
|
46
|
+
'?', '@', ':', ';', ',', '.', '=', '{', '}', '[', ']', '(', ')', '+', '-', '*', '/', '<', '>', '!',
|
|
47
|
+
]),
|
|
48
|
+
// Regular string escapes; any other `\x` yields x.
|
|
49
|
+
stringEscapes: Object.freeze({ n: '\n', r: '\r', t: '\t' }),
|
|
50
|
+
// Indented string escapes after `''\`; any other char yields itself.
|
|
51
|
+
indentedEscapes: Object.freeze({ n: '\n', r: '\r', t: '\t' }),
|
|
52
|
+
pathStart: '^(?:~|[A-Za-z0-9._+-]*)/(?![/*])(?=[A-Za-z0-9._+$-])',
|
|
53
|
+
pathChars: '[A-Za-z0-9._+/~-]',
|
|
54
|
+
searchPath: '^<[A-Za-z0-9._+-]+(?:/[A-Za-z0-9._+-]+)*>',
|
|
55
|
+
uri: "^[A-Za-z][A-Za-z0-9+.-]*:[A-Za-z0-9%/?:@&=+$,\\-_.!~*']+",
|
|
56
|
+
number: '^(?:[0-9]+\\.[0-9]*(?:[Ee][+-]?[0-9]+)?|\\.[0-9]+(?:[Ee][+-]?[0-9]+)?|[0-9]+(?:[Ee][+-]?[0-9]+)?)',
|
|
57
|
+
identifier: "^[A-Za-z_][A-Za-z0-9_'-]*",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const NIX_GRAMMAR_SHA256 = '464c3c4d5f6ae60479d873e3c40aa2d49c1e303fab6c120d6d440792a0e5aa42';
|
|
61
|
+
|
|
62
|
+
export function nixGrammarChecksum(data) {
|
|
63
|
+
return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const REQUIRED = ['keywords', 'binaryOps', 'punctuation', 'stringEscapes', 'indentedEscapes'];
|
|
67
|
+
const REQUIRED_STRINGS = ['pathStart', 'pathChars', 'searchPath', 'uri', 'number', 'identifier'];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Loads and verifies the grammar. Never throws.
|
|
71
|
+
* @param {{grammarSource?: (() => object|null|undefined)}} [opts] test/ops hook that
|
|
72
|
+
* substitutes the data source, so absent and corrupt installs can be exercised.
|
|
73
|
+
* @returns {{available:boolean, grammar?:object, checksum?:string, gap?:{kind:string, detail:string}}}
|
|
74
|
+
*/
|
|
75
|
+
export function loadNixGrammar(opts = {}) {
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = typeof opts.grammarSource === 'function' ? opts.grammarSource() : GRAMMAR_DATA;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { available: false, gap: { kind: 'missing-grammar', detail: `grammar could not be read: ${(err && err.message) || err}` } };
|
|
81
|
+
}
|
|
82
|
+
if (data === null || data === undefined) {
|
|
83
|
+
return { available: false, gap: { kind: 'missing-grammar', detail: 'Nix grammar asset is absent' } };
|
|
84
|
+
}
|
|
85
|
+
let checksum;
|
|
86
|
+
try { checksum = nixGrammarChecksum(data); } catch { checksum = null; }
|
|
87
|
+
if (checksum !== NIX_GRAMMAR_SHA256) {
|
|
88
|
+
return { available: false, gap: { kind: 'corrupt-grammar', detail: `Nix grammar checksum mismatch (got ${checksum || 'unhashable'})` } };
|
|
89
|
+
}
|
|
90
|
+
for (const k of REQUIRED) {
|
|
91
|
+
if (data[k] === null || typeof data[k] !== 'object') return { available: false, gap: { kind: 'corrupt-grammar', detail: `Nix grammar is missing table "${k}"` } };
|
|
92
|
+
}
|
|
93
|
+
for (const k of REQUIRED_STRINGS) {
|
|
94
|
+
if (typeof data[k] !== 'string') return { available: false, gap: { kind: 'corrupt-grammar', detail: `Nix grammar is missing pattern "${k}"` } };
|
|
95
|
+
}
|
|
96
|
+
return { available: true, grammar: data, checksum };
|
|
97
|
+
}
|