@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,42 @@
|
|
|
1
|
+
// Adapter that plugs the Nix parser and config IR into the language pipeline
|
|
2
|
+
// contracts (CORE-003), so parse outcomes reach scan health instead of staying
|
|
3
|
+
// inside the parser. A missing or corrupt grammar becomes `missing_grammar`, a
|
|
4
|
+
// deadline hit becomes `timed_out`, and syntax errors, dynamic attributes,
|
|
5
|
+
// dynamic imports, lazy recursion and IR/parser budget hits become `unresolved`
|
|
6
|
+
// outcomes. Findings from other languages are never touched.
|
|
7
|
+
|
|
8
|
+
import { isRegisteredLanguageProducer, registerLanguageProducer } from './contracts.js';
|
|
9
|
+
import { analyzeNix } from './nix-ir.js';
|
|
10
|
+
import { loadNixGrammar } from './nix-grammar.js';
|
|
11
|
+
|
|
12
|
+
export const NIX_PARSE_PRODUCER = 'language:nix-parse';
|
|
13
|
+
|
|
14
|
+
export function ensureNixParseProducer() {
|
|
15
|
+
if (!isRegisteredLanguageProducer(NIX_PARSE_PRODUCER)) {
|
|
16
|
+
registerLanguageProducer({ id: NIX_PARSE_PRODUCER, language: 'nix', capability: 'parse', evidenceKinds: ['source'], version: '1' });
|
|
17
|
+
}
|
|
18
|
+
return NIX_PARSE_PRODUCER;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{grammarSource?:Function, budgets?:object, irBudgets?:object, onParse?:(file:string, result:object)=>void}} [opts]
|
|
23
|
+
*/
|
|
24
|
+
export function createNixAdapter(opts = {}) {
|
|
25
|
+
ensureNixParseProducer();
|
|
26
|
+
return {
|
|
27
|
+
id: NIX_PARSE_PRODUCER,
|
|
28
|
+
language: 'nix',
|
|
29
|
+
hasGrammar: () => loadNixGrammar({ grammarSource: opts.grammarSource }).available,
|
|
30
|
+
analyze(file, content) {
|
|
31
|
+
const result = analyzeNix(content, { file, grammarSource: opts.grammarSource, budgets: opts.budgets, irBudgets: opts.irBudgets });
|
|
32
|
+
if (typeof opts.onParse === 'function') opts.onParse(file, result);
|
|
33
|
+
const { parse, ir } = result;
|
|
34
|
+
if (parse.status === 'budget_exceeded' && parse.budget && parse.budget.name === 'deadlineMs') {
|
|
35
|
+
throw Object.assign(new Error(`Nix parser deadline exceeded for ${file}`), { code: 'LANG_TIMEOUT' });
|
|
36
|
+
}
|
|
37
|
+
if (parse.status === 'failed' && !parse.errors.length) throw new Error('Nix parse failed');
|
|
38
|
+
const unresolved = ir.unresolved.map((u) => ({ line: u.span ? u.span.startLine : 1, reason: `${u.kind}: ${u.detail}` }));
|
|
39
|
+
return { findings: [], unresolved, parse, ir };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Nix-launched agent and MCP service configuration (X-003).
|
|
2
|
+
//
|
|
3
|
+
// What this reads is DECLARATION: a systemd service or container whose command line starts an MCP server or
|
|
4
|
+
// an agent CLI, with the filesystem roots, flags, bind address, user and environment the configuration gives
|
|
5
|
+
// it. Every finding here is configuration evidence of a CAPABILITY, never proof that an agent exercised it
|
|
6
|
+
// (`declaredCapability: true`, `exercised: 'not-established'`). Bridges to the application that reads the
|
|
7
|
+
// configuration (an environment variable the Nix service sets and the Haskell program reads) carry explicit
|
|
8
|
+
// provenance on both sides, and only become `executable-linked` when the service launches an executable the
|
|
9
|
+
// project's own manifest builds; a matching variable name alone is a `candidate`.
|
|
10
|
+
//
|
|
11
|
+
// Static only: nothing is evaluated or launched.
|
|
12
|
+
|
|
13
|
+
import { parseNix } from './nix-parser.js';
|
|
14
|
+
import { tokenizeHaskell } from './secrets.js';
|
|
15
|
+
import { analyzeHaskellManifests } from './haskell-manifests.js';
|
|
16
|
+
|
|
17
|
+
export const NIX_AGENTS_VERSION = 'nix-agents/1';
|
|
18
|
+
const PH = '';
|
|
19
|
+
|
|
20
|
+
const MCP_RE = /(?:@modelcontextprotocol\/|\bmcp[-_]server|\bserver[-_]mcp|\bmcp[-_]proxy|fastmcp|\buvx\s+mcp|\bnpx\b[^\n]*\bmcp\b|\s--mcp\b)/i;
|
|
21
|
+
const SHELL_MCP_RE = /(?:mcp[-_]server[-_](?:shell|commands|exec)|server-shell|shell-server|mcp-shell|mcp[-_]commands)/i;
|
|
22
|
+
const AGENT_RE = /(?:^|[/\s])(?:claude|aider|goose|codex|gemini|gemini-cli|opencode|crush|openhands|open-interpreter|interpreter|autogpt|cline)(?:\s|$|--)/i;
|
|
23
|
+
const AUTO_APPROVE_RE = /(?:--dangerously-skip-permissions|--dangerously-bypass[\w-]*|--yolo\b|--auto-?approve\b|--approval-mode[= ]yolo|--no-confirm\b|--yes-always\b|--permission-mode[= ]bypass\w*|-y\s+--(?:auto|run))/i;
|
|
24
|
+
const BIND_ALL_RE = /(?:--(?:host|listen|address|bind)[= ]\s*(?:0\.0\.0\.0|::|\[::\]|\*)|-H\s+0\.0\.0\.0|\b(?:HOST|LISTEN_ADDR|BIND(?:_ADDRESS)?)=0\.0\.0\.0)/i;
|
|
25
|
+
const BROAD_ROOTS = [[/(?:^|\s)\/(?:\s|$)/, '/', 'high'], [/(?:^|\s)\/(?:etc|root|nix|boot|proc|sys|usr)(?:\/\S*)?(?:\s|$)/, '/etc, /root, /nix or another system tree', 'high'], [/(?:^|\s)(?:\/home|\/var|\/mnt|\/srv\/?)(?:\s|$)/, '/home, /var or /mnt', 'medium'], [/(?:^|\s)(?:~\/?|\$HOME\/?|%h\/?)(?:\s|$)/, 'the user home directory', 'medium']];
|
|
26
|
+
|
|
27
|
+
const segName = (s) => (s && s.kind === 'static' ? s.name : null);
|
|
28
|
+
const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
|
|
29
|
+
|
|
30
|
+
/** A string's text with every interpolation replaced by a placeholder (never evaluated). */
|
|
31
|
+
function textOf(n) {
|
|
32
|
+
n = unparen(n);
|
|
33
|
+
if (!n) return null;
|
|
34
|
+
if (n.type === 'string') return n.parts.map((p) => (p.kind === 'text' ? p.value : PH)).join('');
|
|
35
|
+
if (n.type === 'list') return n.items.map((i) => textOf(i)).filter((x) => x !== null).join(' ');
|
|
36
|
+
if (n.type === 'path') return n.literal || null;
|
|
37
|
+
if (n.type === 'binop' && n.op === '+') { const a = textOf(n.left); const b = textOf(n.right); return a !== null && b !== null ? a + b : null; }
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectServices(files) {
|
|
42
|
+
const services = new Map();
|
|
43
|
+
const get = (file, name, kind, line) => { const k = `${kind}:${name}`; if (!services.has(k)) services.set(k, { file, name, kind, line, commands: [], env: {}, user: null, dynamicUser: null, openFirewall: null, extra: {} }); return services.get(k); };
|
|
44
|
+
for (const [file, text] of Object.entries(files)) {
|
|
45
|
+
const parse = parseNix(text, { file });
|
|
46
|
+
if (!parse.ast) continue;
|
|
47
|
+
const stack = [{ n: parse.ast, path: [] }];
|
|
48
|
+
while (stack.length) {
|
|
49
|
+
const { n, path } = stack.pop();
|
|
50
|
+
if (!n || typeof n !== 'object') continue;
|
|
51
|
+
if (n.type === 'attrset') {
|
|
52
|
+
for (const b of n.bindings || []) {
|
|
53
|
+
if (b.kind !== 'attr' || !b.value) continue;
|
|
54
|
+
const key = (b.path || []).map(segName);
|
|
55
|
+
if (key.some((x) => x === null)) { stack.push({ n: b.value, path: [...path, ...key.filter(Boolean)] }); continue; }
|
|
56
|
+
const full = [...path, ...key];
|
|
57
|
+
const line = b.span ? b.span.startLine : null;
|
|
58
|
+
let m = /^systemd\.(?:user\.)?services\.([^.]+)\.(.+)$/.exec(full.join('.'));
|
|
59
|
+
let kind = 'systemd';
|
|
60
|
+
const oci = /^virtualisation\.oci-containers\.containers\.([^.]+)\.(.+)$/.exec(full.join('.'));
|
|
61
|
+
if (oci) { m = oci; kind = 'oci'; }
|
|
62
|
+
if (m) {
|
|
63
|
+
const svc = get(file, m[1], kind, line); const rest = m[2];
|
|
64
|
+
const v = unparen(b.value); const t = textOf(v);
|
|
65
|
+
if (kind === 'systemd') {
|
|
66
|
+
if ((/^(?:script|preStart|postStart)$/.test(rest) || /^serviceConfig\.ExecStart(?:Pre)?$/.test(rest)) && t !== null) svc.commands.push({ text: t, line, file, option: rest });
|
|
67
|
+
if (rest === 'serviceConfig.User' && t !== null) svc.user = { value: t, line };
|
|
68
|
+
if (rest === 'serviceConfig.DynamicUser' && v && v.type === 'ident') svc.dynamicUser = { value: v.name === 'true', line };
|
|
69
|
+
const env = /^environment\.(.+)$/.exec(rest);
|
|
70
|
+
if (env && t !== null) svc.env[env[1]] = { text: t, line, file };
|
|
71
|
+
if (rest === 'environment' && v && v.type === 'attrset') for (const eb of v.bindings) { if (eb.kind !== 'attr') continue; const ek = (eb.path || []).map(segName).filter(Boolean).join('.'); const et = textOf(eb.value); if (et !== null) svc.env[ek] = { text: et, line: eb.span ? eb.span.startLine : line, file }; }
|
|
72
|
+
} else {
|
|
73
|
+
if (/^(?:cmd|entrypoint)$/.test(rest) && t !== null) svc.commands.push({ text: t, line, file, option: rest });
|
|
74
|
+
if (rest === 'image' && t !== null) svc.extra.image = t;
|
|
75
|
+
if (rest === 'user' && t !== null) svc.user = { value: t, line };
|
|
76
|
+
if (rest === 'ports' && t !== null) svc.extra.ports = t;
|
|
77
|
+
if (rest === 'volumes' && t !== null) svc.extra.volumes = t;
|
|
78
|
+
const env = /^environment\.(.+)$/.exec(rest);
|
|
79
|
+
if (env && t !== null) svc.env[env[1]] = { text: t, line, file };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
stack.push({ n: b.value, path: full });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const k of Object.keys(n)) { if (k === 'span') continue; const c = n[k]; if (c && typeof c === 'object' && !(n.type === 'attrset' && k === 'bindings')) { if (Array.isArray(c)) for (const x of c) stack.push({ n: x, path }); else stack.push({ n: c, path }); } }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...services.values()];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const finding = (rule, svc, cmd, o) => ({
|
|
92
|
+
id: `${rule}:${svc.file}:${o.line || svc.line}`, file: svc.file, line: o.line || svc.line, parser: 'NIX-AGENT', family: 'agent-config', rule,
|
|
93
|
+
severity: o.severity, cwe: o.cwe, vuln: o.vuln, owaspLlm: 'LLM06',
|
|
94
|
+
description: `${o.desc} This is configuration evidence of a declared capability; nothing shows an agent has exercised it.`,
|
|
95
|
+
remediation: o.fix, language: 'nix', capability: 'iac', analysisKind: 'configuration', evidenceKind: 'config', scope: 'system', confidence: o.confidence ?? 0.7,
|
|
96
|
+
declaredCapability: true, exercised: 'not-established', service: { name: svc.name, kind: svc.kind }, subject: `${svc.kind}:${svc.name}`,
|
|
97
|
+
attrPath: `${svc.kind === 'oci' ? 'virtualisation.oci-containers.containers' : 'systemd.services'}.${svc.name}`,
|
|
98
|
+
chain: [{ file: svc.file, line: o.line || svc.line, label: o.label || o.vuln, kind: 'config' }],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {Record<string,string>} files Nix sources (and, for bridges, Haskell sources and .cabal manifests)
|
|
103
|
+
*/
|
|
104
|
+
export function analyzeNixAgents(files) {
|
|
105
|
+
const nix = Object.fromEntries(Object.entries(files).filter(([p, t]) => /\.nix$/i.test(p) && typeof t === 'string'));
|
|
106
|
+
const findings = []; const agents = []; const bridges = [];
|
|
107
|
+
const services = collectServices(nix);
|
|
108
|
+
for (const svc of services) {
|
|
109
|
+
const all = svc.commands.map((c) => c.text).join('\n');
|
|
110
|
+
const isMcp = MCP_RE.test(all) || MCP_RE.test(svc.name) || (svc.extra.image && MCP_RE.test(svc.extra.image));
|
|
111
|
+
const isAgent = AGENT_RE.test(all);
|
|
112
|
+
if (!isMcp && !isAgent) continue;
|
|
113
|
+
const cmd = svc.commands.find((c) => MCP_RE.test(c.text) || AGENT_RE.test(c.text)) || svc.commands[0] || { text: '', line: svc.line };
|
|
114
|
+
const entry = { name: svc.name, kind: isMcp ? 'mcp-server' : 'agent', launcher: svc.kind, file: svc.file, line: cmd.line || svc.line, user: svc.user ? svc.user.value : null, dynamicUser: svc.dynamicUser ? svc.dynamicUser.value : null, env: Object.keys(svc.env), declaredCapability: true, exercised: 'not-established' };
|
|
115
|
+
agents.push(entry);
|
|
116
|
+
const text = ` ${all.replace(new RegExp(PH, 'g'), ' ')} `;
|
|
117
|
+
if (SHELL_MCP_RE.test(all)) findings.push(finding('nix-mcp-shell-server', svc, cmd, { severity: 'high', cwe: 'CWE-250', vuln: 'An MCP server that executes shell commands is launched as a service', desc: `${svc.name} starts an MCP server whose purpose is to run commands.`, fix: 'Do not expose a general shell to a model. Offer narrow tools, or run it in a sandbox with no network and a read-only root.', line: cmd.line }));
|
|
118
|
+
if (isMcp) for (const [re, what, sev] of BROAD_ROOTS) if (re.test(text)) { findings.push(finding('nix-mcp-broad-filesystem', svc, cmd, { severity: sev, cwe: 'CWE-732', vuln: 'An MCP/agent service is given a broad filesystem root', desc: `${svc.name} is started with ${what} as a root the server may read or write.`, fix: 'Point the server at the one project directory it needs, and add systemd ReadWritePaths/ProtectSystem so the unit cannot reach more.', line: cmd.line })); break; }
|
|
119
|
+
if (AUTO_APPROVE_RE.test(text)) findings.push(finding('nix-agent-auto-approve', svc, cmd, { severity: 'high', cwe: 'CWE-862', vuln: 'An agent is launched with permission prompts disabled', desc: `${svc.name} starts an agent with an auto-approve / skip-permissions flag, so tool use needs no confirmation.`, fix: 'Remove the flag, or run the agent in an isolated unit with a minimal allow-list.', line: cmd.line }));
|
|
120
|
+
const bindAll = BIND_ALL_RE.test(text) || Object.values(svc.env).some((e) => BIND_ALL_RE.test(` ${e.text} `) || /^0\.0\.0\.0$/.test(String(e.text).trim()) && false);
|
|
121
|
+
const envBind = Object.entries(svc.env).find(([k, e]) => /^(?:HOST|LISTEN_ADDR|BIND|BIND_ADDRESS|MCP_HOST)$/i.test(k) && String(e.text).trim() === '0.0.0.0');
|
|
122
|
+
if (bindAll || envBind) findings.push(finding('nix-agent-network-exposed', svc, cmd, { severity: 'medium', cwe: 'CWE-668', vuln: 'An agent/MCP service listens on every interface', desc: `${svc.name} binds 0.0.0.0, so anything that can reach the host can reach the tool surface.`, fix: 'Bind 127.0.0.1 (or a unix socket) and put authentication in front of anything that must be remote.', line: envBind ? envBind[1].line : cmd.line }));
|
|
123
|
+
const root = svc.kind === 'systemd' && (!svc.user || svc.user.value === 'root') && !(svc.dynamicUser && svc.dynamicUser.value);
|
|
124
|
+
if (root) findings.push(finding('nix-agent-runs-as-root', svc, cmd, { severity: 'medium', cwe: 'CWE-250', vuln: 'An agent/MCP service runs as root', desc: `${svc.name} has no User or DynamicUser, so systemd runs it as root.`, fix: 'Set serviceConfig.DynamicUser = true (or a dedicated User) and grant only the paths it needs.', line: svc.line }));
|
|
125
|
+
entry.environment = Object.entries(svc.env).map(([k, e]) => ({ name: k, line: e.line, valueRecorded: false }));
|
|
126
|
+
}
|
|
127
|
+
// bridges: a Nix service environment variable and the Haskell program that reads it
|
|
128
|
+
const hsFiles = Object.entries(files).filter(([p, t]) => /\.l?hs$/i.test(p) && typeof t === 'string');
|
|
129
|
+
const manifests = Object.entries(files).filter(([p, t]) => /\.cabal$/i.test(p) && typeof t === 'string').map(([path, text]) => ({ path, text }));
|
|
130
|
+
const executables = new Set();
|
|
131
|
+
if (manifests.length) { try { for (const pkg of analyzeHaskellManifests(manifests).packages) for (const c of pkg.components || []) if (c.kind === 'executable') executables.add(c.name); } catch { /* no manifest evidence */ } }
|
|
132
|
+
const reads = [];
|
|
133
|
+
for (const [file, text] of hsFiles) {
|
|
134
|
+
const toks = tokenizeHaskell(text);
|
|
135
|
+
for (let i = 0; i < toks.length; i++) {
|
|
136
|
+
const t = toks[i];
|
|
137
|
+
if (t.k !== 'i' || !/^(?:getEnv|lookupEnv)$/.test(t.v.replace(/^.*\./, ''))) continue;
|
|
138
|
+
let j = i + 1; while (toks[j] && toks[j].k === 'x' && toks[j].v === '(') j++;
|
|
139
|
+
if (toks[j] && toks[j].k === 's') { let line = 1; for (let q = 0; q < t.start; q++) if (text.charCodeAt(q) === 10) line++; reads.push({ file, line, variable: toks[j].v }); }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const svc of services) {
|
|
143
|
+
const exec = svc.commands.map((c) => c.text).join(' ');
|
|
144
|
+
const launched = [...executables].filter((e) => new RegExp(`(?:^|[/\\s])${e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|$)`).test(exec.replace(new RegExp(PH, 'g'), ' ')));
|
|
145
|
+
for (const [variable, e] of Object.entries(svc.env)) {
|
|
146
|
+
for (const r of reads.filter((x) => x.variable === variable)) {
|
|
147
|
+
bridges.push({ kind: 'env-read', variable, status: launched.length ? 'executable-linked' : 'candidate', executable: launched[0] || null,
|
|
148
|
+
nix: { service: svc.name, file: e.file, line: e.line, role: 'declares-environment' }, haskell: { file: r.file, line: r.line, role: 'reads-environment' },
|
|
149
|
+
provenance: { nix: 'systemd service environment (configuration)', haskell: 'getEnv/lookupEnv literal (source)', link: launched.length ? `the service launches ${launched[0]}, an executable built by the project's cabal file` : 'only the variable name matches: no evidence this service runs that program' },
|
|
150
|
+
flow: 'a runtime configuration bridge, not a data-flow proof' });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { version: NIX_AGENTS_VERSION, findings, agents, bridges };
|
|
155
|
+
}
|