@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,379 @@
|
|
|
1
|
+
// Flake, legacy input and declared package inventory (NIX-007).
|
|
2
|
+
//
|
|
3
|
+
// Three different things are inventoried and never merged:
|
|
4
|
+
// input a source dependency named by flake.nix (url, follows, flake=false) and, when flake.lock is
|
|
5
|
+
// present, its locked node (fetch type, rev, narHash) and the exact follows-resolved edge graph
|
|
6
|
+
// selector a package SELECTOR such as pkgs.git or `with pkgs; [ curl ]`: a name in an attribute set, not a
|
|
7
|
+
// version. A selector is NEVER resolved to a version here, even when the lock pins nixpkgs: the
|
|
8
|
+
// nixpkgs revision is not the package version, and `pkgs` may be an overlay or another channel.
|
|
9
|
+
// resolved an exact package from a derivation/closure export (NIX-008). Nothing in this module produces one.
|
|
10
|
+
//
|
|
11
|
+
// A lock file describes source inputs. It is not a runtime closure, and the result says so (`closure`).
|
|
12
|
+
// Legacy forms (channels, NIX_PATH, <nixpkgs>, fetchTarball of a branch, local paths) are recorded with the
|
|
13
|
+
// resolution they actually have and the trust warning that follows from it. Static only: nothing here
|
|
14
|
+
// evaluates Nix, contacts a registry or forces flakes.
|
|
15
|
+
|
|
16
|
+
import { redactUrlsDeep } from './secrets.js';
|
|
17
|
+
import { parseNix } from './nix-parser.js';
|
|
18
|
+
import { buildNixIR } from './nix-ir.js';
|
|
19
|
+
import { isLanguageExcludedPath } from './discovery.js';
|
|
20
|
+
|
|
21
|
+
export const NIX_INVENTORY_VERSION = 'nix-inventory/1';
|
|
22
|
+
export const MAX_LOCK_NODES = 20_000;
|
|
23
|
+
const SUPPORTED_LOCK_VERSIONS = new Set([5, 6, 7]);
|
|
24
|
+
const OUTPUT_KINDS = new Set(['packages', 'legacyPackages', 'devShells', 'apps', 'checks', 'nixosConfigurations', 'nixosModules', 'overlays', 'overlay', 'defaultPackage', 'devShell', 'defaultApp', 'homeConfigurations', 'darwinConfigurations', 'templates', 'formatter', 'hydraJobs', 'lib', 'nixosModule']);
|
|
25
|
+
|
|
26
|
+
const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
|
|
27
|
+
const join = (d, f) => (d ? `${d}/${f}` : f);
|
|
28
|
+
|
|
29
|
+
// ── lock parsing ─────────────────────────────────────────────────────────────
|
|
30
|
+
function fetchMeta(entry) {
|
|
31
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
32
|
+
const m = { type: entry.type || null };
|
|
33
|
+
for (const k of ['owner', 'repo', 'url', 'ref', 'rev', 'narHash', 'path', 'dir', 'host', 'id', 'lastModified', 'revCount', 'submodules']) if (entry[k] !== undefined) m[k] = entry[k];
|
|
34
|
+
return m;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveFollows(nodes, rootKey, path, guard) {
|
|
38
|
+
// `follows` is a path of input NAMES from the root: root.inputs[p0] -> node, node.inputs[p1] -> ...
|
|
39
|
+
let cur = rootKey; const walked = [];
|
|
40
|
+
for (const name of path) {
|
|
41
|
+
const n = nodes[cur];
|
|
42
|
+
if (!n || !n.inputs || !(name in n.inputs)) return { error: `follows path ${path.join('/')} does not exist (no input "${name}" on ${cur})` };
|
|
43
|
+
const target = n.inputs[name];
|
|
44
|
+
walked.push(name);
|
|
45
|
+
if (Array.isArray(target)) {
|
|
46
|
+
const key = target.join('/');
|
|
47
|
+
if (guard.has(key)) return { cycle: [...guard, key] };
|
|
48
|
+
guard.add(key);
|
|
49
|
+
const r = resolveFollows(nodes, rootKey, target, guard);
|
|
50
|
+
guard.delete(key);
|
|
51
|
+
if (r.error || r.cycle) return r;
|
|
52
|
+
cur = r.key;
|
|
53
|
+
} else cur = target;
|
|
54
|
+
}
|
|
55
|
+
return { key: cur };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Parse flake.lock text into a labelled graph. Never throws. */
|
|
59
|
+
export function parseFlakeLock(text, file = 'flake.lock') {
|
|
60
|
+
let doc;
|
|
61
|
+
try { doc = JSON.parse(text); } catch (e) { return { status: 'invalid', file, error: String(e.message || e), nodes: [], edges: [], cycles: [], dangling: [], orphans: [] }; }
|
|
62
|
+
if (!doc || typeof doc !== 'object' || typeof doc.nodes !== 'object' || doc.nodes === null) return { status: 'invalid', file, error: 'no "nodes" object', nodes: [], edges: [], cycles: [], dangling: [], orphans: [] };
|
|
63
|
+
const rawNodes = doc.nodes;
|
|
64
|
+
const keys = Object.keys(rawNodes);
|
|
65
|
+
if (keys.length > MAX_LOCK_NODES) return { status: 'budget_exceeded', file, error: `${keys.length} nodes exceed ${MAX_LOCK_NODES}`, nodes: [], edges: [], cycles: [], dangling: [], orphans: [] };
|
|
66
|
+
const rootKey = typeof doc.root === 'string' ? doc.root : 'root';
|
|
67
|
+
const version = Number.isInteger(doc.version) ? doc.version : null;
|
|
68
|
+
const out = { status: 'ok', file, version, versionSupported: SUPPORTED_LOCK_VERSIONS.has(version), root: rootKey, nodes: [], edges: [], cycles: [], dangling: [], orphans: [], problems: [] };
|
|
69
|
+
if (!rawNodes[rootKey]) { out.status = 'invalid'; out.error = `root node "${rootKey}" is missing`; return out; }
|
|
70
|
+
if (!out.versionSupported) out.problems.push(`lock version ${version} is not one of ${[...SUPPORTED_LOCK_VERSIONS].join('/')}; edges are best-effort`);
|
|
71
|
+
|
|
72
|
+
const edgeSet = new Set();
|
|
73
|
+
const reachable = new Set([rootKey]);
|
|
74
|
+
const queue = [rootKey];
|
|
75
|
+
const cycles = new Set();
|
|
76
|
+
while (queue.length) {
|
|
77
|
+
const from = queue.shift();
|
|
78
|
+
const n = rawNodes[from];
|
|
79
|
+
for (const [name, target] of Object.entries((n && n.inputs) || {})) {
|
|
80
|
+
let to = null; let via = 'inputs'; let followsPath = null;
|
|
81
|
+
if (Array.isArray(target)) {
|
|
82
|
+
via = 'follows'; followsPath = target;
|
|
83
|
+
const r = resolveFollows(rawNodes, rootKey, target, new Set([target.join('/')]));
|
|
84
|
+
if (r.cycle) { cycles.add(r.cycle.join(' -> ')); out.edges.push({ from, name, via, followsPath, to: null, status: 'cycle' }); continue; }
|
|
85
|
+
if (r.error) { out.dangling.push({ from, name, reason: r.error }); out.edges.push({ from, name, via, followsPath, to: null, status: 'dangling' }); continue; }
|
|
86
|
+
to = r.key;
|
|
87
|
+
} else if (typeof target === 'string') to = target;
|
|
88
|
+
else { out.dangling.push({ from, name, reason: `input "${name}" has an unusable target` }); continue; }
|
|
89
|
+
if (!rawNodes[to]) { out.dangling.push({ from, name, reason: `edge target "${to}" is not a node` }); out.edges.push({ from, name, via, followsPath, to, status: 'dangling' }); continue; }
|
|
90
|
+
const k = `${from}\u0000${name}`;
|
|
91
|
+
if (edgeSet.has(k)) continue;
|
|
92
|
+
edgeSet.add(k);
|
|
93
|
+
out.edges.push({ from, name, via, followsPath, to, status: 'ok' });
|
|
94
|
+
if (!reachable.has(to)) { reachable.add(to); queue.push(to); }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
out.cycles = [...cycles];
|
|
98
|
+
// a node reachable from itself through plain (non-follows) edges is also a cycle
|
|
99
|
+
const adj = new Map();
|
|
100
|
+
for (const e of out.edges) if (e.status === 'ok') { if (!adj.has(e.from)) adj.set(e.from, []); adj.get(e.from).push(e.to); }
|
|
101
|
+
const color = new Map();
|
|
102
|
+
const dfs = (u, stack) => {
|
|
103
|
+
color.set(u, 1);
|
|
104
|
+
for (const v of adj.get(u) || []) {
|
|
105
|
+
if (color.get(v) === 1) out.cycles.push([...stack, u, v].join(' -> '));
|
|
106
|
+
else if (!color.get(v)) dfs(v, [...stack, u]);
|
|
107
|
+
}
|
|
108
|
+
color.set(u, 2);
|
|
109
|
+
};
|
|
110
|
+
dfs(rootKey, []);
|
|
111
|
+
out.cycles = [...new Set(out.cycles)];
|
|
112
|
+
// nodes
|
|
113
|
+
const nameOf = new Map();
|
|
114
|
+
for (const e of out.edges) if (e.from === rootKey && e.to && e.status === 'ok' && !nameOf.has(e.to)) nameOf.set(e.to, e.name);
|
|
115
|
+
for (const k of keys) {
|
|
116
|
+
const n = rawNodes[k];
|
|
117
|
+
const locked = fetchMeta(n.locked); const original = fetchMeta(n.original);
|
|
118
|
+
const isRoot = k === rootKey;
|
|
119
|
+
out.nodes.push({
|
|
120
|
+
key: k, root: isRoot, name: isRoot ? null : (nameOf.get(k) || k),
|
|
121
|
+
flake: n.flake !== false, componentClass: 'input', kind: 'source-dependency',
|
|
122
|
+
locked, original, reachable: reachable.has(k),
|
|
123
|
+
resolution: isRoot ? null : lockResolution(locked),
|
|
124
|
+
inputs: Object.keys(n.inputs || {}),
|
|
125
|
+
});
|
|
126
|
+
if (!reachable.has(k)) out.orphans.push(k);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function lockResolution(l) {
|
|
132
|
+
if (!l) return 'unlocked';
|
|
133
|
+
if (l.rev && l.narHash) return 'locked-rev-and-hash';
|
|
134
|
+
if (l.rev) return 'locked-rev';
|
|
135
|
+
if (l.narHash) return 'locked-hash-only';
|
|
136
|
+
if (l.type === 'path') return 'local-path';
|
|
137
|
+
if (l.type === 'indirect') return 'unresolved-indirect';
|
|
138
|
+
return 'floating';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function warningsFor(node) {
|
|
142
|
+
const w = [];
|
|
143
|
+
const l = node.locked; const o = node.original;
|
|
144
|
+
if (node.root) return w;
|
|
145
|
+
if (!l) w.push({ kind: 'unlocked', detail: 'no locked fetch metadata for this node' });
|
|
146
|
+
else {
|
|
147
|
+
if (l.type === 'path') w.push({ kind: 'local-path', detail: `local path input${l.path ? ` (${l.path})` : ''}: not fetched from a pinned remote` });
|
|
148
|
+
if (l.type === 'indirect') w.push({ kind: 'indirect-registry', detail: `indirect reference "${l.id}" depends on the flake registry at evaluation time` });
|
|
149
|
+
if (l.type !== 'path' && l.type !== 'indirect' && !l.rev && !l.narHash) w.push({ kind: 'no-integrity', detail: 'locked without rev or narHash' });
|
|
150
|
+
if (l.type !== 'path' && l.type !== 'indirect' && !l.narHash) w.push({ kind: 'no-narhash', detail: 'no narHash: the fetched content is not integrity-checked' });
|
|
151
|
+
if (l.type === 'tarball' && !l.rev) w.push({ kind: 'tarball-no-rev', detail: 'tarball input has no source revision, only a content hash' });
|
|
152
|
+
}
|
|
153
|
+
if (o && o.ref && l && l.rev) { /* ref pinned by rev: fine */ }
|
|
154
|
+
return w;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── flake.nix ────────────────────────────────────────────────────────────────
|
|
158
|
+
function declaredInputs(file, text) {
|
|
159
|
+
const parse = parseNix(text, { file });
|
|
160
|
+
if (!parse.ast) return { ok: false, inputs: [], outputs: [], status: parse.status };
|
|
161
|
+
const ir = buildNixIR(parse, { file, source: text });
|
|
162
|
+
if (ir.fileKind !== 'flake' || !ir.flake) return { ok: false, inputs: [], outputs: [], status: 'not-a-flake', ir };
|
|
163
|
+
const outputs = [];
|
|
164
|
+
for (const b of ir.bindings) {
|
|
165
|
+
const head = b.path[0];
|
|
166
|
+
if (OUTPUT_KINDS.has(head) && b.origin !== 'let') outputs.push({ kind: head, path: b.path.filter(Boolean), attr: b.pathText, line: b.span ? b.span.startLine : null, span: b.span || null, dynamic: b.dynamic || b.path.includes(null) });
|
|
167
|
+
}
|
|
168
|
+
return { ok: true, inputs: ir.flake.inputs, outputs, status: parse.status, description: ir.flake.description, ir, params: ir.flake.outputs ? ir.flake.outputs.params : [] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── legacy / selectors: AST walks ────────────────────────────────────────────
|
|
172
|
+
const FETCHERS = new Set(['fetchTarball', 'fetchurl', 'fetchGit', 'fetchTree', 'fetchzip', 'fetchMercurial']);
|
|
173
|
+
const SELECTOR_ROLES = [
|
|
174
|
+
[/(^|\.)environment\.systemPackages$/, 'system'], [/(^|\.)users\.users\.[^.]+\.packages$/, 'user'], [/(^|\.)home\.packages$/, 'user'], [/(^|\.)(?:nativeBuildInputs|buildInputs|propagatedBuildInputs|checkInputs|nativeCheckInputs|installCheckInputs|depsBuildBuild)$/, 'build'],
|
|
175
|
+
[/(^|\.)(?:packages|buildInputs|nativeBuildInputs)$/, 'dev'], [/(^|\.)package$/, 'service'], [/(^|\.)extraPackages$/, 'service'], [/(^|\.)path$/, 'service'],
|
|
176
|
+
];
|
|
177
|
+
const roleOf = (attrPath) => { const p = attrPath.filter(Boolean).join('.'); for (const [re, role] of SELECTOR_ROLES) if (re.test(p)) return role; return 'unspecified'; };
|
|
178
|
+
const segName = (s) => (s && s.kind === 'static' ? s.name : null);
|
|
179
|
+
const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
|
|
180
|
+
|
|
181
|
+
function walkAst(root, cb) {
|
|
182
|
+
const stack = [{ n: root, path: [], withs: [] }];
|
|
183
|
+
while (stack.length) {
|
|
184
|
+
const { n, path, withs } = stack.pop();
|
|
185
|
+
if (!n || typeof n !== 'object') continue;
|
|
186
|
+
cb(n, path, withs);
|
|
187
|
+
switch (n.type) {
|
|
188
|
+
case 'attrset': case 'let': {
|
|
189
|
+
for (const b of n.bindings || []) {
|
|
190
|
+
if (b.kind === 'attr') { const key = (b.path || []).map(segName); stack.push({ n: b.value, path: n.type === 'let' ? [] : [...path, ...key], withs }); }
|
|
191
|
+
}
|
|
192
|
+
if (n.type === 'let') stack.push({ n: n.body, path, withs });
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case 'with': { const base = unparen(n.env); const nm = base && base.type === 'ident' ? base.name : (base && base.type === 'select' && unparen(base.base) && unparen(base.base).type === 'ident' ? unparen(base.base).name : null); stack.push({ n: n.env, path, withs }); stack.push({ n: n.body, path, withs: [...withs, nm] }); break; }
|
|
196
|
+
case 'lambda': stack.push({ n: n.body, path, withs }); break;
|
|
197
|
+
case 'app': stack.push({ n: n.fn, path, withs }); stack.push({ n: n.arg, path, withs }); break;
|
|
198
|
+
case 'list': for (const it of n.items) stack.push({ n: it, path, withs }); break;
|
|
199
|
+
case 'binop': stack.push({ n: n.left, path, withs }); stack.push({ n: n.right, path, withs }); break;
|
|
200
|
+
case 'if': stack.push({ n: n.then, path, withs }); stack.push({ n: n.else, path, withs }); break;
|
|
201
|
+
case 'paren': stack.push({ n: n.expr, path, withs }); break;
|
|
202
|
+
case 'select': stack.push({ n: n.base, path, withs }); break;
|
|
203
|
+
case 'string': for (const p of n.parts || []) if (p.kind === 'interp') stack.push({ n: p.expr, path, withs }); break;
|
|
204
|
+
default: break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function collectLegacyAndSelectors(file, text, flakeProvider) {
|
|
210
|
+
const parse = parseNix(text, { file });
|
|
211
|
+
const legacy = []; const selectors = [];
|
|
212
|
+
if (!parse.ast) return { legacy, selectors };
|
|
213
|
+
const line = (n) => (n && n.span ? n.span.startLine : null);
|
|
214
|
+
walkAst(parse.ast, (n, path, withs) => {
|
|
215
|
+
if (n.type === 'spath') legacy.push({ kind: 'angle-bracket', value: `<${n.value}>`, file, line: line(n), resolution: 'nix-path-lookup', warnings: [{ kind: 'nix-path', detail: `<${n.value}> resolves through NIX_PATH at evaluation time: the result depends on the evaluating machine` }] });
|
|
216
|
+
if (n.type === 'attrset') {
|
|
217
|
+
for (const b of n.bindings || []) {
|
|
218
|
+
if (b.kind !== 'attr') continue;
|
|
219
|
+
const key = (b.path || []).map(segName).filter(Boolean).join('.');
|
|
220
|
+
const full = [...path.filter(Boolean), ...(b.path || []).map(segName).filter(Boolean)].join('.');
|
|
221
|
+
const v = unparen(b.value);
|
|
222
|
+
if (/(?:^|\.)nix\.nixPath$/.test(full) && v && v.type === 'list') {
|
|
223
|
+
for (const it of v.items) { const s = unparen(it); legacy.push({ kind: 'nixPath', value: s && s.literal !== undefined ? s.literal : null, file, line: line(it), resolution: s && s.literal && /^[^=]*=(?:flake:|\/nix\/store\/)/.test(s.literal) ? 'pinned-path' : 'nix-path-entry', warnings: [{ kind: 'nix-path', detail: 'nix.nixPath entries feed <...> lookups; they are not part of any lock' }] }); }
|
|
224
|
+
}
|
|
225
|
+
if (/(?:^|\.)nix\.channel\.enable$/.test(full)) legacy.push({ kind: 'channel-setting', value: v && v.type === 'ident' ? v.name : null, file, line: line(b), resolution: 'setting', warnings: [] });
|
|
226
|
+
if (/(?:^|\.)system\.autoUpgrade\.(?:channel|flake)$/.test(full) && v && v.literal !== undefined) legacy.push({ kind: /channel$/.test(full) ? 'channel' : 'auto-upgrade-flake', value: v.literal, file, line: line(b), resolution: /channel$/.test(full) ? 'floating-channel' : 'flake-ref', warnings: /channel$/.test(full) ? [{ kind: 'floating-channel', detail: `channel ${v.literal} moves over time; nothing pins a revision` }] : [] });
|
|
227
|
+
if (/(?:^|\.)nix\.registry\.[^.]+\.(?:to|from)$/.test(full) || /(?:^|\.)nix\.registry\./.test(full)) legacy.push({ kind: 'registry', value: full, file, line: line(b), resolution: 'registry-entry', warnings: [] });
|
|
228
|
+
const sel = SELECTOR_ROLES.some(([re]) => re.test(full)) ? roleOf(full.split('.')) : null;
|
|
229
|
+
if (sel && v) collectSelectors(v, full.split('.'), sel, withs, file, selectors, flakeProvider);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (n.type === 'app') {
|
|
233
|
+
let fn = unparen(n.fn); const args = [n.arg];
|
|
234
|
+
while (fn && fn.type === 'app') { args.unshift(fn.arg); fn = unparen(fn.fn); }
|
|
235
|
+
const name = fn && fn.type === 'ident' ? fn.name : (fn && fn.type === 'select' ? segName(fn.attrpath[fn.attrpath.length - 1]) : null);
|
|
236
|
+
const root = fn && fn.type === 'select' ? (unparen(fn.base) || {}).name : null;
|
|
237
|
+
if (name && FETCHERS.has(name) && (root === 'builtins' || root === 'pkgs' || !root || true)) legacy.push(fetcherEntry(name, args, file, line(n)));
|
|
238
|
+
if (name === 'getFlake' && root === 'builtins') { const a = unparen(args[0]); legacy.push({ kind: 'getFlake', value: a && a.literal !== undefined ? a.literal : null, file, line: line(n), resolution: a && a.literal && /[?&]rev=[0-9a-f]{40}/.test(a.literal) ? 'rev-pinned' : 'floating-ref', warnings: [{ kind: 'impure-eval', detail: 'builtins.getFlake fetches at evaluation time and is not recorded in the lock of the calling flake' }] }); }
|
|
239
|
+
if (name === 'import' && fn && fn.type === 'ident') { const a = unparen(args[0]); if (a && a.type === 'path' && a.literal && /^(?:\/|~)/.test(a.literal)) legacy.push({ kind: 'local-path', value: a.literal, file, line: line(n), resolution: 'absolute-path', warnings: [{ kind: 'local-path', detail: `${a.literal} is outside the repository: the build depends on the machine` }] }); }
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
return { legacy, selectors };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function fetcherEntry(name, args, file, ln) {
|
|
246
|
+
const a = unparen(args[0]);
|
|
247
|
+
let url = null; let sha = false; let rev = null; let ref = null;
|
|
248
|
+
if (a && a.type === 'string' && a.literal !== undefined) url = a.literal;
|
|
249
|
+
if (a && a.type === 'attrset') {
|
|
250
|
+
for (const b of a.bindings) {
|
|
251
|
+
if (b.kind !== 'attr') continue; const k = (b.path || []).map(segName).join('.');
|
|
252
|
+
const v = unparen(b.value);
|
|
253
|
+
if (k === 'url' && v && v.literal !== undefined) url = v.literal;
|
|
254
|
+
if (/^(?:sha256|hash)$/.test(k)) sha = true;
|
|
255
|
+
if (k === 'rev' && v && v.literal !== undefined) rev = v.literal;
|
|
256
|
+
if (k === 'ref' && v && v.literal !== undefined) ref = v.literal;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const pinnedByUrl = url && /\/archive\/[0-9a-f]{40}\.(?:tar|zip)|\?rev=[0-9a-f]{40}|\/[0-9a-f]{40}(?:\.tar|\/)/.test(url);
|
|
260
|
+
const floatingUrl = url && /\/archive\/(?:refs\/heads\/)?[A-Za-z][\w.-]*\.(?:tar\.gz|tgz|zip)$/.test(url) && !pinnedByUrl;
|
|
261
|
+
let resolution;
|
|
262
|
+
if (rev && /^[0-9a-f]{40}$/.test(rev) && (sha || name === 'fetchGit')) resolution = sha ? 'rev-and-hash' : 'rev-pinned';
|
|
263
|
+
else if (rev && /^[0-9a-f]{40}$/.test(rev)) resolution = 'rev-pinned';
|
|
264
|
+
else if (sha) resolution = 'hash-pinned';
|
|
265
|
+
else if (pinnedByUrl) resolution = 'rev-in-url';
|
|
266
|
+
else if (floatingUrl || ref) resolution = 'floating-ref';
|
|
267
|
+
else resolution = 'unpinned';
|
|
268
|
+
const warnings = [];
|
|
269
|
+
if (resolution === 'unpinned' || resolution === 'floating-ref') warnings.push({ kind: 'unpinned-fetch', detail: `${name} of ${url || 'a computed url'}${ref ? ` (ref ${ref})` : ''} has no revision or hash: its content changes over time` });
|
|
270
|
+
if (!sha && name !== 'fetchGit' && resolution !== 'rev-in-url') warnings.push({ kind: 'no-integrity', detail: `${name} has no sha256/hash` });
|
|
271
|
+
return { kind: name, value: url, file, line: ln, resolution, rev: rev || null, ref: ref || null, integrity: sha ? 'hash' : 'none', warnings };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function collectSelectors(v, attrPath, role, withs, file, out, flakeProvider) {
|
|
275
|
+
const items = [];
|
|
276
|
+
const walk = (n) => {
|
|
277
|
+
n = unparen(n);
|
|
278
|
+
if (!n) return;
|
|
279
|
+
if (n.type === 'list') for (const it of n.items) walk(it);
|
|
280
|
+
else if (n.type === 'binop') { walk(n.left); walk(n.right); }
|
|
281
|
+
else if (n.type === 'with') { const b = unparen(n.env); const nm = b && b.type === 'ident' ? b.name : (b && b.type === 'select' ? (unparen(b.base) || {}).name : null); collectSelectorsIn(n.body, [...withs, nm]); }
|
|
282
|
+
else if (n.type === 'if') { walk(n.then); walk(n.else); }
|
|
283
|
+
else items.push(n);
|
|
284
|
+
};
|
|
285
|
+
const collectSelectorsIn = (body, ws) => { const b = unparen(body); if (b && b.type === 'list') for (const it of b.items) classify(unparen(it), ws); else if (b) classify(b, ws); };
|
|
286
|
+
const classify = (n, ws) => {
|
|
287
|
+
if (!n) return;
|
|
288
|
+
if (n.type === 'ident' && ws.includes('pkgs')) out.push({ ...sel(n.name, 'pkgs', n, `pkgs.${n.name}`), via: 'with pkgs' });
|
|
289
|
+
else if (n.type === 'select') {
|
|
290
|
+
const base = unparen(n.base); const segs = n.attrpath.map(segName);
|
|
291
|
+
if (base && base.type === 'ident' && base.name === 'pkgs' && segs.every(Boolean)) out.push(sel(segs.join('.'), 'pkgs', n, `pkgs.${segs.join('.')}`));
|
|
292
|
+
else if (base && base.type === 'ident' && /^(?:nixpkgs|inputs)$/.test(base.name) && segs.every(Boolean)) {
|
|
293
|
+
const lp = segs.indexOf('legacyPackages'); const pk = segs.indexOf('packages');
|
|
294
|
+
const idx = lp >= 0 ? lp : pk;
|
|
295
|
+
if (idx >= 0 && segs.length > idx + 2) out.push(sel(segs.slice(idx + 2).join('.'), `${base.name}.${segs.slice(0, idx + 1).join('.')}`, n, `${base.name}.${segs.join('.')}`));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
const sel = (attr, provider, n, text) => ({
|
|
300
|
+
componentClass: 'selector', kind: 'software-package', selector: text, attr, provider, role, attrPath: attrPath.filter(Boolean).join('.'),
|
|
301
|
+
file, line: n.span ? n.span.startLine : null, span: n.span || null,
|
|
302
|
+
version: null, versionResolved: false, status: 'selector-only',
|
|
303
|
+
providerEvidence: provider === 'pkgs' ? { status: 'module-argument', input: flakeProvider ? flakeProvider.name : null, note: 'pkgs is a module argument: it may be an overlay, a different channel or a pinned import, so the lock is not claimed as its source' } : { status: 'named-input', input: provider.split('.')[0] },
|
|
304
|
+
});
|
|
305
|
+
walk(v);
|
|
306
|
+
for (const n of items) classify(n, withs);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── orchestration ────────────────────────────────────────────────────────────
|
|
310
|
+
/**
|
|
311
|
+
* @param {{files: Record<string,string>}} opts rel path -> content
|
|
312
|
+
*/
|
|
313
|
+
export function analyzeNixInputs(opts = {}) {
|
|
314
|
+
const files = {};
|
|
315
|
+
for (const [p, t] of Object.entries(opts.files || {})) if (typeof t === 'string' && !isLanguageExcludedPath(p) && (/\.nix$/i.test(p) || /(^|\/)flake\.lock$/i.test(p))) files[p] = t;
|
|
316
|
+
const out = { version: NIX_INVENTORY_VERSION, flakes: [], legacy: [], selectors: [], components: [], gaps: [], closure: { complete: false, status: 'not-computed', reason: 'flake.lock and flake.nix describe source inputs and package selectors; a runtime closure needs an explicit target-specific export (NIX-008)' } };
|
|
317
|
+
const flakeFiles = Object.keys(files).filter((f) => /(^|\/)flake\.nix$/.test(f)).sort();
|
|
318
|
+
const lockOwners = new Set();
|
|
319
|
+
for (const ff of flakeFiles) {
|
|
320
|
+
const dir = dirOf(ff);
|
|
321
|
+
const lockPath = join(dir, 'flake.lock');
|
|
322
|
+
const decl = declaredInputs(ff, files[ff]);
|
|
323
|
+
const flake = { file: ff, dir, status: decl.status, description: decl.description || null, declared: [], outputs: decl.outputs || [], outputParams: decl.params || [], lock: null, graph: null, warnings: [], componentClass: 'input' };
|
|
324
|
+
if (!decl.ok) { out.gaps.push({ kind: 'flake-not-parsed', file: ff, detail: `flake.nix could not be read as a flake (${decl.status})` }); out.flakes.push(flake); continue; }
|
|
325
|
+
let lock;
|
|
326
|
+
if (typeof files[lockPath] === 'string') { lock = parseFlakeLock(files[lockPath], lockPath); lockOwners.add(lockPath); }
|
|
327
|
+
else lock = { status: 'missing', file: lockPath, nodes: [], edges: [], cycles: [], dangling: [], orphans: [] };
|
|
328
|
+
flake.lock = { status: lock.status, file: lockPath, version: lock.version ?? null, error: lock.error || null, problems: lock.problems || [] };
|
|
329
|
+
if (lock.status === 'missing') out.gaps.push({ kind: 'missing-flake-lock', file: ff, detail: `${lockPath} is absent: inputs are unlocked and resolve at evaluation time` });
|
|
330
|
+
if (lock.status === 'invalid' || lock.status === 'budget_exceeded') out.gaps.push({ kind: 'invalid-flake-lock', file: lockPath, detail: lock.error });
|
|
331
|
+
for (const c of lock.cycles) out.gaps.push({ kind: 'lock-cycle', file: lockPath, detail: `cycle in lock graph: ${c}` });
|
|
332
|
+
for (const d of lock.dangling) out.gaps.push({ kind: 'lock-dangling-edge', file: lockPath, detail: `${d.from}.${d.name}: ${d.reason}` });
|
|
333
|
+
const rootEdges = new Map(lock.edges.filter((e) => e.from === lock.root).map((e) => [e.name, e]));
|
|
334
|
+
const nodeByKey = new Map(lock.nodes.map((n) => [n.key, n]));
|
|
335
|
+
for (const inp of decl.inputs) {
|
|
336
|
+
const e = rootEdges.get(inp.name);
|
|
337
|
+
const node = e && e.to ? nodeByKey.get(e.to) : null;
|
|
338
|
+
const row = {
|
|
339
|
+
name: inp.name, url: inp.url, flake: inp.flake, follows: inp.follows, nestedFollows: inp.nestedFollows, dynamic: !!inp.dynamic,
|
|
340
|
+
line: inp.span ? inp.span.startLine : null, locked: !!node, lockNode: node ? node.key : null,
|
|
341
|
+
sourceKind: inp.flake === false ? 'nonflake-source' : 'flake', componentClass: 'input', kind: 'source-dependency',
|
|
342
|
+
status: lock.status === 'ok' ? (node ? 'locked' : (inp.follows ? 'follows-declared' : 'declared-not-in-lock')) : (lock.status === 'missing' ? 'unlocked' : 'lock-unusable'),
|
|
343
|
+
lockedFetch: node ? node.locked : null, originalFetch: node ? node.original : null, resolution: node ? node.resolution : 'unlocked',
|
|
344
|
+
warnings: [],
|
|
345
|
+
};
|
|
346
|
+
if (node) row.warnings.push(...warningsFor(node));
|
|
347
|
+
if (node && inp.url && node.original) {
|
|
348
|
+
const want = String(inp.url);
|
|
349
|
+
const o = node.original;
|
|
350
|
+
const ref = o.type === 'github' ? `github:${o.owner}/${o.repo}${o.ref ? `/${o.ref}` : ''}` : null;
|
|
351
|
+
if (ref && /^github:/.test(want) && want.replace(/\?.*$/, '').replace(/\/[0-9a-f]{40}$/, '') !== ref && want.replace(/\?.*$/, '') !== ref) row.warnings.push({ kind: 'lock-url-mismatch', detail: `flake.nix declares ${want} but the lock was made for ${ref}: the lock is stale for this input` });
|
|
352
|
+
}
|
|
353
|
+
if (!node && lock.status === 'ok' && !inp.follows) row.warnings.push({ kind: 'unlocked-input', detail: `input ${inp.name} is declared but not present in flake.lock` });
|
|
354
|
+
flake.declared.push(row);
|
|
355
|
+
}
|
|
356
|
+
if (lock.status === 'ok') {
|
|
357
|
+
const declaredNames = new Set(decl.inputs.map((i) => i.name));
|
|
358
|
+
for (const [name, e] of rootEdges) if (!declaredNames.has(name)) flake.warnings.push({ kind: 'stale-lock-input', detail: `flake.lock has input "${name}" that flake.nix no longer declares`, name, node: e.to });
|
|
359
|
+
for (const o of lock.orphans) flake.warnings.push({ kind: 'orphan-lock-node', detail: `lock node ${o} is not reachable from the root`, node: o });
|
|
360
|
+
}
|
|
361
|
+
flake.graph = { root: lock.root || null, nodes: lock.nodes.map((n) => ({ ...n, warnings: warningsFor(n) })), edges: lock.edges, cycles: lock.cycles, dangling: lock.dangling, orphans: lock.orphans };
|
|
362
|
+
out.flakes.push(flake);
|
|
363
|
+
// components: reachable lock nodes (source dependencies), de-duplicated by node key
|
|
364
|
+
const flakeProvider = flake.declared.find((d) => d.name === 'nixpkgs') || null;
|
|
365
|
+
for (const n of flake.graph.nodes) if (!n.root && n.reachable) out.components.push({ componentClass: 'input', kind: 'source-dependency', name: n.name, node: n.key, flake: n.flake, fetch: n.locked, original: n.original, resolution: n.resolution, flakeFile: ff, warnings: n.warnings, version: null, versionResolved: false });
|
|
366
|
+
flake._provider = flakeProvider ? { name: flakeProvider.name, rev: flakeProvider.lockedFetch && flakeProvider.lockedFetch.rev, ref: flakeProvider.originalFetch && flakeProvider.originalFetch.ref } : null;
|
|
367
|
+
}
|
|
368
|
+
for (const p of Object.keys(files)) if (/(^|\/)flake\.lock$/.test(p) && !lockOwners.has(p)) out.gaps.push({ kind: 'lock-without-flake', file: p, detail: 'flake.lock has no flake.nix next to it; its inputs are listed without declarations' });
|
|
369
|
+
// legacy forms and package selectors from every Nix source
|
|
370
|
+
const provider = out.flakes.length && out.flakes[0]._provider ? out.flakes[0]._provider : null;
|
|
371
|
+
for (const f of Object.keys(files).filter((p) => /\.nix$/i.test(p)).sort()) {
|
|
372
|
+
const r = collectLegacyAndSelectors(f, files[f], provider);
|
|
373
|
+
out.legacy.push(...r.legacy); out.selectors.push(...r.selectors);
|
|
374
|
+
}
|
|
375
|
+
for (const f of out.flakes) delete f._provider;
|
|
376
|
+
for (const s of out.selectors) out.components.push({ ...s });
|
|
377
|
+
out.coverage = { flakes: out.flakes.length, locks: out.flakes.filter((f) => f.lock && f.lock.status === 'ok').length, inputs: out.components.filter((c) => c.componentClass === 'input').length, selectors: out.selectors.length, legacy: out.legacy.length };
|
|
378
|
+
return redactUrlsDeep(out);
|
|
379
|
+
}
|