@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1164 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +17 -2
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// NixOS service, access and host hardening rules (NIX-004).
|
|
2
|
+
//
|
|
3
|
+
// Judges the EFFECTIVE configuration produced by nixos-module-resolver.js (NIX-002),
|
|
4
|
+
// never raw text, so a commented-out assignment, an option on a disabled service, a
|
|
5
|
+
// renamed option spelled the old way, or a mkForce safe override is judged by what the
|
|
6
|
+
// module system would actually select. Every finding carries the option source(s),
|
|
7
|
+
// the effective value, the catalog revision and release, a severity and a remediation
|
|
8
|
+
// rationale.
|
|
9
|
+
//
|
|
10
|
+
// Three different claims are kept apart and never merged:
|
|
11
|
+
// enabled the service/option is switched on in the effective config;
|
|
12
|
+
// listening it opens a port in the model (ssh ports, an option that binds all
|
|
13
|
+
// interfaces, a published container port);
|
|
14
|
+
// reachable a remote party can connect. Network topology (cloud security groups,
|
|
15
|
+
// NAT, upstream firewalls) is not in the configuration, so this is ALWAYS
|
|
16
|
+
// `unknown`; only the host firewall is modelled (`ingress`).
|
|
17
|
+
//
|
|
18
|
+
// A finding is capped at `medium` and marked `conditional` when the service state or
|
|
19
|
+
// the offending value depends on an undecidable condition, and when the module graph is
|
|
20
|
+
// partial. A definition that is inactive (service disabled/absent, condition false) never
|
|
21
|
+
// produces a finding. Nothing here runs `nix`, evaluates a derivation, or scans image
|
|
22
|
+
// layers: OCI/nixos-container declarations are modelled as declarations only.
|
|
23
|
+
|
|
24
|
+
import { resolveNixosConfig } from './nixos-module-resolver.js';
|
|
25
|
+
import { parseNix } from './nix-parser.js';
|
|
26
|
+
import { normalizeRelease } from './nixos-option-catalog.js';
|
|
27
|
+
|
|
28
|
+
export const HARDENING_RULESET_VERSION = 'nixos-hardening/1';
|
|
29
|
+
|
|
30
|
+
const SEV_ORDER = ['info', 'low', 'medium', 'high', 'critical'];
|
|
31
|
+
const capSeverity = (sev, max) => (SEV_ORDER.indexOf(sev) > SEV_ORDER.indexOf(max) ? max : sev);
|
|
32
|
+
|
|
33
|
+
/** Policy families. `severity` is the BASE; `ruleVersion` bumps when a rule's meaning changes. */
|
|
34
|
+
export const HARDENING_RULES = Object.freeze({
|
|
35
|
+
'ssh-root-login': { family: 'ssh-access', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'SSH permits direct root login with a password', why: 'A direct root login removes per-user accountability and gives an attacker who guesses or phishes one credential immediate full control.', fix: 'Set services.openssh.settings.PermitRootLogin = "no" (or "prohibit-password" with key-only access) and administer through a named wheel user.' },
|
|
36
|
+
'ssh-password-auth': { family: 'ssh-access', cwe: 'CWE-307', severity: 'medium', ruleVersion: 1, vuln: 'SSH accepts password authentication', why: 'Passwords can be guessed or reused; key-only authentication removes the online guessing surface.', fix: 'Set services.openssh.settings.PasswordAuthentication = false and services.openssh.settings.KbdInteractiveAuthentication = false, with authorized keys on the users.' },
|
|
37
|
+
'ssh-empty-passwords': { family: 'ssh-access', cwe: 'CWE-258', severity: 'high', ruleVersion: 1, vuln: 'SSH permits empty passwords', why: 'An account with an empty password becomes a login with no secret at all.', fix: 'Remove services.openssh.settings.PermitEmptyPasswords or set it to false.' },
|
|
38
|
+
'firewall-disabled': { family: 'firewall-exposure', cwe: 'CWE-284', severity: 'high', ruleVersion: 1, vuln: 'Host firewall is disabled', why: 'With the NixOS firewall off every listening service is open on every interface, and the only barrier left is whatever network sits in front of the host.', fix: 'Remove networking.firewall.enable = false and open only the ports each service needs.' },
|
|
39
|
+
'firewall-sensitive-port': { family: 'firewall-exposure', cwe: 'CWE-668', severity: 'high', ruleVersion: 1, vuln: 'Firewall opens a database or administration port on every interface', why: 'Database, cache and container-control ports are not designed to face untrusted networks; a global allow rule exposes them wherever the host is routable.', fix: 'Remove the port from networking.firewall.allowedTCPPorts/allowedUDPPorts, or scope it with networking.firewall.interfaces.<name>.allowedTCPPorts to a private interface.' },
|
|
40
|
+
'listener-all-interfaces': { family: 'firewall-exposure', cwe: 'CWE-668', severity: 'medium', ruleVersion: 1, vuln: 'Service is configured to listen on every interface', why: 'Binding all interfaces widens the set of networks that can reach the service beyond the host itself.', fix: 'Leave the listener on localhost, or restrict listen addresses and the firewall to the interface that needs access.' },
|
|
41
|
+
'listener-trust-auth': { family: 'firewall-exposure', cwe: 'CWE-306', severity: 'high', ruleVersion: 1, vuln: 'Database authentication is "trust" for a network-wide address range', why: 'A trust rule for 0.0.0.0/0 lets any client that reaches the port in as any user without a password.', fix: 'Use scram-sha-256 in services.postgresql.authentication and limit the address range.' },
|
|
42
|
+
'service-runs-as-root': { family: 'service-identity', cwe: 'CWE-250', severity: 'medium', ruleVersion: 1, vuln: 'systemd service runs as root', why: 'A compromise of a root service is a compromise of the host; a dedicated or dynamic user confines it.', fix: 'Set serviceConfig.DynamicUser = true or serviceConfig.User to a dedicated account, and add NoNewPrivileges and ProtectSystem.' },
|
|
43
|
+
'service-exec-elevated': { family: 'service-identity', cwe: 'CWE-250', severity: 'medium', ruleVersion: 1, vuln: 'ExecStart uses a privilege-preserving prefix', why: 'A "+" or "!" prefix runs the command with full privileges even when User is set, defeating the identity the service appears to have.', fix: 'Drop the "+"/"!" prefix, or move the privileged step into a separate, minimal ExecStartPre unit.' },
|
|
44
|
+
'systemd-dangerous-capability': { family: 'systemd-privilege', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'systemd service holds a dangerous Linux capability', why: 'CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE, CAP_DAC_READ_SEARCH and similar are close to root for escape and tampering purposes.', fix: 'Remove the capability from AmbientCapabilities/CapabilityBoundingSet, or grant the narrowest one the service needs.' },
|
|
45
|
+
'systemd-device-access': { family: 'systemd-privilege', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'systemd service may access raw memory or all devices', why: 'Read/write access to /dev/mem, /dev/kmem or every character device bypasses process isolation.', fix: 'Remove the DeviceAllow entry and keep PrivateDevices = true.' },
|
|
46
|
+
'systemd-filesystem-relaxed': { family: 'systemd-privilege', cwe: 'CWE-732', severity: 'medium', ruleVersion: 1, vuln: 'systemd service filesystem protection is disabled or the root is writable', why: 'ProtectSystem = false, a writable "/" or PrivateTmp = false removes the read-only/isolated view hardening provides.', fix: 'Use ProtectSystem = "strict" with explicit ReadWritePaths, and PrivateTmp = true.' },
|
|
47
|
+
'systemd-no-new-privileges-off': { family: 'systemd-privilege', cwe: 'CWE-269', severity: 'low', ruleVersion: 1, vuln: 'NoNewPrivileges is explicitly disabled', why: 'With it off, a compromised process can gain privileges through setuid binaries or file capabilities.', fix: 'Remove serviceConfig.NoNewPrivileges = false, or set it to true.' },
|
|
48
|
+
'sudo-passwordless-wheel': { family: 'privilege-escalation', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'Members of wheel get passwordless root through sudo', why: 'Any process running as a wheel user, or anyone with that user\'s session, becomes root with no second factor.', fix: 'Leave security.sudo.wheelNeedsPassword at its default (true), or grant NOPASSWD only to a specific command.' },
|
|
49
|
+
'sudo-nopasswd-all': { family: 'privilege-escalation', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'sudoers grants NOPASSWD for ALL commands', why: 'An unrestricted passwordless rule is equivalent to root for the matched user or group.', fix: 'Restrict the rule to the exact commands needed, or require a password.' },
|
|
50
|
+
'doas-nopass': { family: 'privilege-escalation', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'doas permits passwordless privilege escalation', why: 'A "permit nopass" rule with no command restriction is passwordless root for the matched identity.', fix: 'Add a "cmd" restriction and drop nopass, or require authentication.' },
|
|
51
|
+
'tls-verification-disabled': { family: 'tls-runtime', cwe: 'CWE-295', severity: 'medium', ruleVersion: 1, vuln: 'Service environment disables TLS certificate verification', why: 'An environment variable such as NODE_TLS_REJECT_UNAUTHORIZED = "0" is a declared intent to accept any certificate, which allows man-in-the-middle interception. It does not prove the application reads it.', fix: 'Remove the variable and provide the CA through the service trust store instead.' },
|
|
52
|
+
'tls-key-in-store': { family: 'tls-runtime', cwe: 'CWE-312', severity: 'high', ruleVersion: 1, vuln: 'TLS private key is copied into the world-readable Nix store', why: 'A Nix path literal (or an interpolated path) is imported into /nix/store when the configuration is evaluated, and every local user can read the store. A private key must be a runtime path the service reads at start.', fix: 'Reference the key as a string outside the store, provisioned at runtime (systemd credentials, agenix, sops-nix): `sslCertificateKey = "/run/credentials/app.key";`.' },
|
|
53
|
+
'tls-not-enforced': { family: 'tls-runtime', cwe: 'CWE-319', severity: 'medium', ruleVersion: 1, vuln: 'Virtual host explicitly serves cleartext HTTP (forceSSL = false)', why: 'forceSSL = false is a deliberate choice to keep the plain-HTTP listener open for the whole host, so credentials and session data cross the network unencrypted.', fix: 'Set `forceSSL = true` (with `enableACME = true` or a certificate) so HTTP is redirected to HTTPS.' },
|
|
54
|
+
'container-privileged': { family: 'container-declaration', cwe: 'CWE-250', severity: 'high', ruleVersion: 1, vuln: 'OCI container is declared privileged or with a dangerous mount', why: '--privileged, SYS_ADMIN/ALL capabilities and the container runtime socket give the container control of the host. Image layers are NOT scanned.', fix: 'Remove --privileged and the runtime socket mount; add only the specific capability the workload needs.' },
|
|
55
|
+
'container-host-network': { family: 'container-declaration', cwe: 'CWE-668', severity: 'medium', ruleVersion: 1, vuln: 'OCI container shares the host network namespace', why: 'Host networking removes network isolation, so the container can reach and bind host-local services. Image layers are NOT scanned.', fix: 'Use the default bridge and publish only the needed ports on a specific address.' },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Which rules explain each privileged entry-point kind (reconciliation join). */
|
|
59
|
+
const ENTRY_RULES = {
|
|
60
|
+
'ssh-root-login': ['ssh-root-login'],
|
|
61
|
+
'sudo-nopasswd': ['sudo-passwordless-wheel', 'sudo-nopasswd-all'],
|
|
62
|
+
'doas-nopass': ['doas-nopass'],
|
|
63
|
+
'root-service': ['service-runs-as-root'],
|
|
64
|
+
'elevated-exec': ['service-exec-elevated'],
|
|
65
|
+
'privileged-container': ['container-privileged'],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const SENSITIVE_PORTS =new Map([[2375, 'Docker API'], [2379, 'etcd'], [3306, 'MySQL'], [5432, 'PostgreSQL'], [5984, 'CouchDB'], [6379, 'Redis'], [9200, 'Elasticsearch'], [11211, 'memcached'], [27017, 'MongoDB']]);
|
|
69
|
+
const DANGEROUS_CAPS = new Set(['CAP_SYS_ADMIN', 'CAP_SYS_MODULE', 'CAP_SYS_PTRACE', 'CAP_SYS_RAWIO', 'CAP_DAC_READ_SEARCH', 'CAP_DAC_OVERRIDE', 'CAP_SYS_BOOT', 'CAP_MAC_ADMIN', 'CAP_BPF', 'CAP_NET_ADMIN', 'ALL']);
|
|
70
|
+
const TLS_OFF = [['NODE_TLS_REJECT_UNAUTHORIZED', (v) => v === '0'], ['GIT_SSL_NO_VERIFY', (v) => /^(1|true|yes)$/i.test(v)], ['PYTHONHTTPSVERIFY', (v) => v === '0'], ['CURL_INSECURE', (v) => /^(1|true|yes)$/i.test(v)]];
|
|
71
|
+
|
|
72
|
+
const lineOf = (s) => (s && s.span ? s.span.startLine : null);
|
|
73
|
+
const asList = (v) => (Array.isArray(v) ? v : []);
|
|
74
|
+
const stripNixHash = (text) => String(text).split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
75
|
+
|
|
76
|
+
/** Effective-value view of one option result. */
|
|
77
|
+
function view(res) {
|
|
78
|
+
if (!res) return { state: 'unknown' };
|
|
79
|
+
if ((res.status === 'set' || res.status === 'default') && res.valueKnown) {
|
|
80
|
+
return { state: 'known', value: res.value, via: res.status === 'default' ? 'catalog-default' : 'explicit', definite: !!res.definite };
|
|
81
|
+
}
|
|
82
|
+
if (res.status === 'conditional') return { state: 'conditional', possible: res.possibleValues || [], allKnown: !!res.allPossibleValuesKnown, definiteItems: res.definiteItems, possibleItems: res.possibleItems };
|
|
83
|
+
return { state: 'unknown' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Does the option satisfy `pred`? -> 'yes' | 'no' | 'conditional' | 'unknown'.
|
|
88
|
+
* 'conditional' means a value that satisfies it is possible but not certain.
|
|
89
|
+
*/
|
|
90
|
+
function judge(res, pred) {
|
|
91
|
+
const v = view(res);
|
|
92
|
+
if (v.state === 'known') return pred(v.value) ? 'yes' : 'no';
|
|
93
|
+
if (v.state === 'conditional') {
|
|
94
|
+
if (v.possible.some((p) => pred(p))) return 'conditional';
|
|
95
|
+
return v.allKnown ? 'no' : 'unknown';
|
|
96
|
+
}
|
|
97
|
+
return 'unknown';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function evidenceOf(res) {
|
|
101
|
+
const v = view(res);
|
|
102
|
+
return {
|
|
103
|
+
option: res.path, namespace: res.namespace, status: res.status,
|
|
104
|
+
effectiveValue: v.state === 'known' ? v.value : undefined,
|
|
105
|
+
valueSource: v.state === 'known' ? v.via : v.state,
|
|
106
|
+
possibleValues: v.state === 'conditional' ? v.possible : undefined,
|
|
107
|
+
precedence: res.precedence,
|
|
108
|
+
sources: res.sources.map((s) => ({ file: s.file, line: lineOf(s), priority: s.priority, priorityLabel: s.priorityLabel, role: s.role, conditions: (s.conditions || []).map((c) => ({ text: c.text, outcome: c.outcome })) })),
|
|
109
|
+
catalog: res.catalog,
|
|
110
|
+
caveats: (res.caveats || []).map((c) => c.kind),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function anchorOf(res) {
|
|
115
|
+
const pick = res.sources.find((s) => s.role === 'winner') || res.sources.find((s) => s.role === 'conditional') || res.sources[0];
|
|
116
|
+
return pick ? { file: pick.file, line: lineOf(pick) } : { file: null, line: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Effective-value helpers, shared with the build-trust rules (NIX-005).
|
|
120
|
+
export { view as effectiveView, judge as judgeOption, evidenceOf as optionEvidence, anchorOf as optionAnchor };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {Parameters<typeof resolveNixosConfig>[0]} opts same input as resolveNixosConfig
|
|
124
|
+
* @param {{config?: object}} [extra] an already resolved config to reuse
|
|
125
|
+
*/
|
|
126
|
+
/** The right-hand-side AST node of the winning definition of `res`, read from its source span; null when unavailable. */
|
|
127
|
+
function rhsNodeOf(res, files) {
|
|
128
|
+
const pick = res && (res.sources || []).find((x) => x.role === 'winner') || (res && (res.sources || [])[0]);
|
|
129
|
+
const text = pick && files && files[pick.file];
|
|
130
|
+
if (!pick || typeof text !== 'string' || !pick.span) return null;
|
|
131
|
+
const slice = text.slice(pick.span.startOffset, pick.span.endOffset);
|
|
132
|
+
let ast;
|
|
133
|
+
try { ast = parseNix(`{ ${slice} }`).ast; } catch { return null; }
|
|
134
|
+
const b = ast && ast.bindings && ast.bindings[0];
|
|
135
|
+
return b && b.value ? b.value : null;
|
|
136
|
+
}
|
|
137
|
+
const hasPathNode = (n, depth = 0) => {
|
|
138
|
+
if (!n || typeof n !== 'object' || depth > 12) return false;
|
|
139
|
+
if (n.type === 'path') return true;
|
|
140
|
+
for (const k of Object.keys(n)) { const v = n[k]; if (Array.isArray(v)) { if (v.some((x) => hasPathNode(x, depth + 1))) return true; } else if (v && typeof v === 'object' && hasPathNode(v, depth + 1)) return true; }
|
|
141
|
+
return false;
|
|
142
|
+
};
|
|
143
|
+
const attrOf = (set, name) => (set && set.type === 'attrset' ? (set.bindings || []).find((b) => b.kind === 'attr' && b.path && b.path.length === 1 && b.path[0].name === name) : null);
|
|
144
|
+
const isTrueNode = (n) => !!n && n.type === 'ident' && n.name === 'true';
|
|
145
|
+
|
|
146
|
+
export function analyzeNixosHardening(opts, extra = {}) {
|
|
147
|
+
const cfg = extra.config || resolveNixosConfig(opts);
|
|
148
|
+
const findings = [];
|
|
149
|
+
const controls = [];
|
|
150
|
+
const gaps = [];
|
|
151
|
+
const byPath = new Map(cfg.options.map((o) => [`${o.namespace}|${o.scope || ''}|${o.path}`, o]));
|
|
152
|
+
const nixos = (p) => byPath.get(`nixos||${p}`) || cfg.lookup(p);
|
|
153
|
+
const partialGraph = cfg.completeness !== 'complete';
|
|
154
|
+
const note = (res, purpose) => {
|
|
155
|
+
if (res && (res.status === 'conflict' || res.status === 'unresolved-precedence')) gaps.push({ kind: res.status, option: res.path, purpose, reason: res.reason });
|
|
156
|
+
return res;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ── host-level facts ──
|
|
160
|
+
const fwEnableRes = note(nixos('networking.firewall.enable'), 'firewall');
|
|
161
|
+
const fwOn = judge(fwEnableRes, (x) => x === true);
|
|
162
|
+
const fwOff = judge(fwEnableRes, (x) => x === false);
|
|
163
|
+
const allowedTcp = view(note(nixos('networking.firewall.allowedTCPPorts'), 'firewall'));
|
|
164
|
+
const allowedUdp = view(note(nixos('networking.firewall.allowedUDPPorts'), 'firewall'));
|
|
165
|
+
const globalPorts = (v) => (v.state === 'known' ? asList(v.value) : v.state === 'conditional' ? asList(v.definiteItems) : []);
|
|
166
|
+
const allowedAll = new Set([...globalPorts(allowedTcp), ...globalPorts(allowedUdp)]);
|
|
167
|
+
const allowedKnown = allowedTcp.state === 'known' && allowedUdp.state === 'known';
|
|
168
|
+
const interfaceScoped = cfg.options.filter((o) => o.namespace === 'nixos' && /^networking\.firewall\.interfaces\.[^.]+\.allowedTCPPorts$/.test(o.path));
|
|
169
|
+
|
|
170
|
+
/** Host-firewall ingress for a TCP port; internet reachability itself is never decided. */
|
|
171
|
+
function ingressFor(port, { openFirewallRes } = {}) {
|
|
172
|
+
if (fwOff === 'yes') return 'firewall-disabled';
|
|
173
|
+
if (fwOn !== 'yes') return 'unknown';
|
|
174
|
+
if (allowedAll.has(port)) return 'open-in-firewall';
|
|
175
|
+
if (openFirewallRes) {
|
|
176
|
+
const o = judge(openFirewallRes, (x) => x === true);
|
|
177
|
+
if (o === 'yes') return 'open-in-firewall';
|
|
178
|
+
if (o === 'conditional' || o === 'unknown') return 'unknown';
|
|
179
|
+
}
|
|
180
|
+
return allowedKnown ? (interfaceScoped.some((o) => asList(view(o).value).includes(port)) ? 'restricted-interface' : 'firewall-closed') : 'unknown';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const inventory = [];
|
|
184
|
+
const entryPoints = [];
|
|
185
|
+
const authz = [];
|
|
186
|
+
const attackPaths = [];
|
|
187
|
+
|
|
188
|
+
function addFinding(rule, { severity, anchor, evidence, scope, state = 'active', uncertain = false, extra: more = {}, mitigations = [] }) {
|
|
189
|
+
const r = HARDENING_RULES[rule];
|
|
190
|
+
const conditional = state === 'conditional';
|
|
191
|
+
let sev = severity || r.severity;
|
|
192
|
+
if (conditional || uncertain || partialGraph) sev = capSeverity(sev, 'medium');
|
|
193
|
+
const uncertainty = [];
|
|
194
|
+
if (conditional) uncertainty.push({ kind: 'unresolved-branch', detail: 'the service or value depends on a condition that could not be decided statically' });
|
|
195
|
+
if (partialGraph) uncertainty.push({ kind: 'unresolved-import', detail: 'the module graph is partial; an unread module could override this value' });
|
|
196
|
+
if (uncertain && !conditional) uncertainty.push({ kind: 'unresolved-branch', detail: 'the effective value carries evaluation caveats' });
|
|
197
|
+
findings.push({
|
|
198
|
+
id: `nixos-hardening:${rule}:${scope}`, subject: scope,
|
|
199
|
+
severity: sev, file: anchor.file, line: anchor.line,
|
|
200
|
+
vuln: r.vuln, cwe: r.cwe,
|
|
201
|
+
description: `${r.vuln} (${scope}). ${r.why}`,
|
|
202
|
+
remediation: r.fix,
|
|
203
|
+
remediationRationale: r.why,
|
|
204
|
+
parser: 'nixos-hardening', family: r.family,
|
|
205
|
+
language: 'nix', capability: 'iac', evidenceKind: 'config', scope: 'system',
|
|
206
|
+
rule, ruleVersion: r.ruleVersion, rulesetVersion: HARDENING_RULESET_VERSION, baseSeverity: r.severity,
|
|
207
|
+
conditional, uncertainty: uncertainty.length ? uncertainty : undefined,
|
|
208
|
+
evidence, mitigations: mitigations.length ? mitigations : undefined,
|
|
209
|
+
...more,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const stateOf = (res) => {
|
|
214
|
+
const j = judge(res, (x) => x === true);
|
|
215
|
+
return j === 'yes' ? 'active' : j === 'conditional' ? 'conditional' : j === 'no' ? 'inactive' : 'unknown';
|
|
216
|
+
};
|
|
217
|
+
const hasDefs = (prefix) => cfg.options.some((o) => o.namespace === 'nixos' && o.path.startsWith(prefix));
|
|
218
|
+
|
|
219
|
+
// ── sshd ──
|
|
220
|
+
if (hasDefs('services.openssh.') || (nixos('services.openssh.enable').sources || []).length) {
|
|
221
|
+
const enableRes = note(nixos('services.openssh.enable'), 'ssh');
|
|
222
|
+
const openFwRes = nixos('services.openssh.openFirewall');
|
|
223
|
+
const portsRes = nixos('services.openssh.ports');
|
|
224
|
+
const state = stateOf(enableRes);
|
|
225
|
+
const portsV = view(portsRes);
|
|
226
|
+
const ports = portsV.state === 'known' ? asList(portsV.value).filter((p) => typeof p === 'number') : [];
|
|
227
|
+
const ingress = ports.length ? (ports.map((p) => ingressFor(p, { openFirewallRes: openFwRes })).find((i) => i !== 'firewall-closed') || 'firewall-closed') : 'unknown';
|
|
228
|
+
inventory.push({
|
|
229
|
+
kind: 'declared-service', name: 'openssh', state, listening: state === 'active' ? 'yes' : state === 'conditional' ? 'unknown' : 'no', ports, ingress,
|
|
230
|
+
reachability: { internet: 'unknown', reason: 'external network topology is not part of the configuration' },
|
|
231
|
+
evidence: [evidenceOf(enableRes), evidenceOf(openFwRes)],
|
|
232
|
+
});
|
|
233
|
+
if (state === 'active' || state === 'conditional') {
|
|
234
|
+
const sshState = state;
|
|
235
|
+
const restricted = ingress === 'firewall-closed' || ingress === 'restricted-interface';
|
|
236
|
+
const mit = restricted ? [{ kind: 'restrictive-ingress', ingress, detail: 'the host firewall does not open the ssh port globally' }] : [];
|
|
237
|
+
const cap = (sev) => (restricted ? capSeverity(sev, 'medium') : sev);
|
|
238
|
+
|
|
239
|
+
const rootRes = note(nixos('services.openssh.settings.PermitRootLogin'), 'ssh');
|
|
240
|
+
const root = judge(rootRes, (x) => x === 'yes');
|
|
241
|
+
if (root === 'yes' || root === 'conditional') {
|
|
242
|
+
const st = root === 'conditional' || sshState === 'conditional' ? 'conditional' : 'active';
|
|
243
|
+
addFinding('ssh-root-login', { severity: cap('high'), anchor: anchorOf(rootRes), scope: 'services.openssh', state: st, uncertain: !view(rootRes).definite, evidence: [evidenceOf(rootRes), evidenceOf(enableRes)], extra: { exposure: { enabled: true, listening: 'yes', ingress, reachable: 'unknown' } }, mitigations: mit });
|
|
244
|
+
entryPoints.push({ id: 'ssh-root-login', kind: 'ssh-root-login', state: st, privilege: 'root', evidence: [evidenceOf(rootRes)] });
|
|
245
|
+
} else if (view(rootRes).state === 'known') {
|
|
246
|
+
controls.push({ control: 'ssh-root-login-restricted', value: view(rootRes).value, option: rootRes.path, applies: true });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const pwRes = note(nixos('services.openssh.settings.PasswordAuthentication'), 'ssh');
|
|
250
|
+
const pw = judge(pwRes, (x) => x === true);
|
|
251
|
+
if (pw === 'yes' || pw === 'conditional') {
|
|
252
|
+
const st = pw === 'conditional' || sshState === 'conditional' ? 'conditional' : 'active';
|
|
253
|
+
addFinding('ssh-password-auth', { severity: cap('medium'), anchor: anchorOf(pwRes), scope: 'services.openssh', state: st, uncertain: !view(pwRes).definite, evidence: [evidenceOf(pwRes)], extra: { exposure: { enabled: true, listening: 'yes', ingress, reachable: 'unknown' }, valueSource: view(pwRes).via }, mitigations: mit });
|
|
254
|
+
} else if (view(pwRes).state === 'known') controls.push({ control: 'ssh-key-only', option: pwRes.path, applies: true });
|
|
255
|
+
|
|
256
|
+
const emptyRes = nixos('services.openssh.settings.PermitEmptyPasswords');
|
|
257
|
+
const empty = judge(emptyRes, (x) => x === true || x === 'yes');
|
|
258
|
+
if (empty === 'yes' || empty === 'conditional') {
|
|
259
|
+
addFinding('ssh-empty-passwords', { severity: cap('high'), anchor: anchorOf(emptyRes), scope: 'services.openssh', state: empty === 'conditional' || sshState === 'conditional' ? 'conditional' : 'active', uncertain: !view(emptyRes).definite, evidence: [evidenceOf(emptyRes)], mitigations: mit });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const auth = pw === 'no' ? 'key-only' : pw === 'yes' ? 'password' : 'unknown';
|
|
263
|
+
if (root === 'yes' || root === 'conditional') {
|
|
264
|
+
authz.push({ entryPoint: 'ssh-root-login', subject: 'network-client', authentication: auth, privilege: 'root', evidence: [evidenceOf(rootRes), evidenceOf(pwRes)] });
|
|
265
|
+
attackPaths.push({ id: 'attack-path:ssh-root', entryPoint: 'ssh-root-login', steps: ['network client reaches sshd', `authenticates (${auth}) as root`, 'root shell'], requires: ['internet or network reachability of the ssh port (unknown)'], ingress, reachable: 'unknown', state: root === 'yes' && sshState === 'active' ? 'active' : 'conditional' });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── sudo / doas ──
|
|
271
|
+
{
|
|
272
|
+
const sudoEnable = nixos('security.sudo.enable');
|
|
273
|
+
const sudoState = stateOf(sudoEnable);
|
|
274
|
+
const wheelRes = note(nixos('security.sudo.wheelNeedsPassword'), 'sudo');
|
|
275
|
+
const wheel = judge(wheelRes, (x) => x === false);
|
|
276
|
+
if ((wheel === 'yes' || wheel === 'conditional') && sudoState !== 'inactive' && sudoState !== 'unknown') {
|
|
277
|
+
const st = wheel === 'conditional' || sudoState === 'conditional' ? 'conditional' : 'active';
|
|
278
|
+
addFinding('sudo-passwordless-wheel', { anchor: anchorOf(wheelRes), scope: 'security.sudo', state: st, uncertain: !view(wheelRes).definite, evidence: [evidenceOf(wheelRes), evidenceOf(sudoEnable)] });
|
|
279
|
+
entryPoints.push({ id: 'sudo-wheel-nopasswd', kind: 'sudo-nopasswd', state: st, privilege: 'root', evidence: [evidenceOf(wheelRes)] });
|
|
280
|
+
authz.push({ entryPoint: 'sudo-wheel-nopasswd', subject: 'local-wheel-user', authentication: 'none', privilege: 'root', evidence: [evidenceOf(wheelRes)] });
|
|
281
|
+
attackPaths.push({ id: 'attack-path:wheel-sudo', entryPoint: 'sudo-wheel-nopasswd', steps: ['code runs as a wheel user', 'sudo without a password', 'root'], requires: ['a foothold as a wheel user'], state: st });
|
|
282
|
+
} else if (view(wheelRes).state === 'known' && view(wheelRes).value === true) controls.push({ control: 'sudo-password-required', option: wheelRes.path, applies: sudoState === 'active' });
|
|
283
|
+
|
|
284
|
+
const sudoCfg = note(nixos('security.sudo.extraConfig'), 'sudo');
|
|
285
|
+
const sv = view(sudoCfg);
|
|
286
|
+
if (sv.state === 'known' && typeof sv.value === 'string' && sudoState !== 'inactive' && sudoState !== 'unknown') {
|
|
287
|
+
const text = stripNixHash(sv.value);
|
|
288
|
+
if (/NOPASSWD\s*:\s*ALL\b/.test(text)) {
|
|
289
|
+
addFinding('sudo-nopasswd-all', { anchor: anchorOf(sudoCfg), scope: 'security.sudo.extraConfig', state: sudoState === 'conditional' ? 'conditional' : 'active', uncertain: !sv.definite, evidence: [evidenceOf(sudoCfg)] });
|
|
290
|
+
entryPoints.push({ id: 'sudo-nopasswd-all', kind: 'sudo-nopasswd', state: sudoState === 'conditional' ? 'conditional' : 'active', privilege: 'root', evidence: [evidenceOf(sudoCfg)] });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const doasEnable = nixos('security.doas.enable');
|
|
295
|
+
const doasState = stateOf(doasEnable);
|
|
296
|
+
const doasCfg = note(nixos('security.doas.extraConfig'), 'doas');
|
|
297
|
+
const dv = view(doasCfg);
|
|
298
|
+
if (dv.state === 'known' && typeof dv.value === 'string' && (doasState === 'active' || doasState === 'conditional')) {
|
|
299
|
+
const rules = stripNixHash(dv.value).split('\n').filter((l) => /^\s*permit\b/.test(l) && /\bnopass\b/.test(l));
|
|
300
|
+
if (rules.length) {
|
|
301
|
+
const unrestricted = rules.some((l) => !/\bcmd\b/.test(l));
|
|
302
|
+
const st = doasState === 'conditional' ? 'conditional' : 'active';
|
|
303
|
+
addFinding('doas-nopass', { severity: unrestricted ? 'high' : 'medium', anchor: anchorOf(doasCfg), scope: 'security.doas.extraConfig', state: st, uncertain: !dv.definite, evidence: [evidenceOf(doasCfg), evidenceOf(doasEnable)], extra: { restrictedToCommand: !unrestricted } });
|
|
304
|
+
entryPoints.push({ id: 'doas-nopass', kind: 'doas-nopass', state: st, privilege: 'root', evidence: [evidenceOf(doasCfg)] });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// The structured form: `security.doas.extraRules = [ { users = [..]; noPass = true; cmd = ".."; } ]`.
|
|
308
|
+
if (doasState === 'active' || doasState === 'conditional') {
|
|
309
|
+
const rulesRes = note(nixos('security.doas.extraRules'), 'doas');
|
|
310
|
+
const list = rhsNodeOf(rulesRes, opts.files);
|
|
311
|
+
const items = list && list.type === 'list' ? list.items : [];
|
|
312
|
+
const nopass = items.filter((it) => isTrueNode((attrOf(it, 'noPass') || {}).value));
|
|
313
|
+
if (nopass.length) {
|
|
314
|
+
const unrestricted = nopass.some((it) => !attrOf(it, 'cmd'));
|
|
315
|
+
const st = doasState === 'conditional' ? 'conditional' : 'active';
|
|
316
|
+
addFinding('doas-nopass', { severity: unrestricted ? 'high' : 'medium', anchor: anchorOf(rulesRes), scope: 'security.doas.extraRules', state: st, uncertain: false, evidence: [evidenceOf(rulesRes), evidenceOf(doasEnable)], extra: { restrictedToCommand: !unrestricted } });
|
|
317
|
+
entryPoints.push({ id: 'doas-nopass', kind: 'doas-nopass', state: st, privilege: 'root', evidence: [evidenceOf(rulesRes)] });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── databases / listeners ──
|
|
323
|
+
if (hasDefs('services.postgresql.')) {
|
|
324
|
+
const enableRes = nixos('services.postgresql.enable');
|
|
325
|
+
const state = stateOf(enableRes);
|
|
326
|
+
let tcpRes = nixos('services.postgresql.enableTCPIP');
|
|
327
|
+
let tcp = judge(tcpRes, (x) => x === true);
|
|
328
|
+
// `settings.listen_addresses` is the current spelling of the same decision: a wildcard binds every interface
|
|
329
|
+
const listenRes = nixos('services.postgresql.settings.listen_addresses');
|
|
330
|
+
const wildcardListen = judge(listenRes, (x) => typeof x === 'string' && x.split(',').map((a) => a.trim()).some((a) => a === '*' || a === '0.0.0.0' || a === '::'));
|
|
331
|
+
if (tcp !== 'yes' && (wildcardListen === 'yes' || wildcardListen === 'conditional')) { tcpRes = listenRes; tcp = wildcardListen; }
|
|
332
|
+
const ingress = ingressFor(5432);
|
|
333
|
+
inventory.push({ kind: 'declared-service', name: 'postgresql', state, listening: state === 'active' ? (tcp === 'yes' ? 'yes' : tcp === 'no' ? 'localhost-only' : 'unknown') : state === 'conditional' ? 'unknown' : 'no', ports: [5432], ingress, reachability: { internet: 'unknown', reason: 'external network topology is not part of the configuration' }, evidence: [evidenceOf(enableRes), evidenceOf(tcpRes)] });
|
|
334
|
+
if ((state === 'active' || state === 'conditional') && (tcp === 'yes' || tcp === 'conditional')) {
|
|
335
|
+
const st = state === 'conditional' || tcp === 'conditional' ? 'conditional' : 'active';
|
|
336
|
+
const restricted = ingress === 'firewall-closed' || ingress === 'restricted-interface';
|
|
337
|
+
addFinding('listener-all-interfaces', { severity: restricted ? 'low' : ingress === 'open-in-firewall' || ingress === 'firewall-disabled' ? 'high' : 'medium', anchor: anchorOf(tcpRes), scope: 'services.postgresql', state: st, uncertain: !view(tcpRes).definite, evidence: [evidenceOf(tcpRes), evidenceOf(enableRes)], extra: { exposure: { enabled: true, listening: 'yes', ingress, reachable: 'unknown' } }, mitigations: restricted ? [{ kind: 'restrictive-ingress', ingress }] : [] });
|
|
338
|
+
}
|
|
339
|
+
const authRes = nixos('services.postgresql.authentication');
|
|
340
|
+
const av = view(authRes);
|
|
341
|
+
if (av.state === 'known' && typeof av.value === 'string' && (state === 'active' || state === 'conditional')) {
|
|
342
|
+
const bad = stripNixHash(av.value).split('\n').some((l) => /^\s*host\S*\s+\S+\s+\S+\s+(0\.0\.0\.0\/0|::\/0)\s+trust\b/.test(l));
|
|
343
|
+
if (bad) addFinding('listener-trust-auth', { anchor: anchorOf(authRes), scope: 'services.postgresql.authentication', state: state === 'conditional' ? 'conditional' : 'active', uncertain: !av.definite, evidence: [evidenceOf(authRes)] });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (hasDefs('services.nginx.')) {
|
|
347
|
+
const enableRes = nixos('services.nginx.enable');
|
|
348
|
+
const state = stateOf(enableRes);
|
|
349
|
+
inventory.push({ kind: 'declared-service', name: 'nginx', state, listening: state === 'active' ? 'unknown' : state === 'conditional' ? 'unknown' : 'no', ports: [], ingress: 'unknown', reachability: { internet: 'unknown', reason: 'listen addresses are generated by the module and external topology is not modelled' }, evidence: [evidenceOf(enableRes)] });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── TLS material and enforcement on nginx virtual hosts ──
|
|
353
|
+
if (hasDefs('services.nginx.')) {
|
|
354
|
+
const nginxState = stateOf(nixos('services.nginx.enable'));
|
|
355
|
+
if (nginxState === 'active' || nginxState === 'conditional') {
|
|
356
|
+
const st = nginxState === 'conditional' ? 'conditional' : 'active';
|
|
357
|
+
for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^services\.nginx\.virtualHosts\..+\.(?:sslCertificateKey|sslTrustedCertificate)$/.test(x.path))) {
|
|
358
|
+
const node = rhsNodeOf(o, opts.files);
|
|
359
|
+
if (node && hasPathNode(node)) addFinding('tls-key-in-store', { anchor: anchorOf(o), scope: o.path, state: st, uncertain: false, evidence: [evidenceOf(o)] });
|
|
360
|
+
else if (view(o).state === 'known' && typeof view(o).value === 'string') controls.push({ control: 'tls-key-runtime-path', option: o.path, applies: true });
|
|
361
|
+
}
|
|
362
|
+
for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^services\.nginx\.virtualHosts\..+\.forceSSL$/.test(x.path))) {
|
|
363
|
+
const j = judge(o, (x) => x === false);
|
|
364
|
+
if (j === 'yes' || j === 'conditional') addFinding('tls-not-enforced', { anchor: anchorOf(o), scope: o.path, state: j === 'conditional' || st === 'conditional' ? 'conditional' : 'active', uncertain: !view(o).definite, evidence: [evidenceOf(o)] });
|
|
365
|
+
else if (judge(o, (x) => x === true) === 'yes') controls.push({ control: 'tls-enforced', option: o.path, applies: true });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── firewall ──
|
|
371
|
+
const listeningNow = inventory.filter((i) => i.state === 'active' && i.listening !== 'no' && i.listening !== 'localhost-only');
|
|
372
|
+
if (fwOff === 'yes' || fwOff === 'conditional') {
|
|
373
|
+
const st = fwOff === 'conditional' ? 'conditional' : 'active';
|
|
374
|
+
addFinding('firewall-disabled', { severity: listeningNow.length ? 'high' : 'medium', anchor: anchorOf(fwEnableRes), scope: 'networking.firewall', state: st, uncertain: !view(fwEnableRes).definite, evidence: [evidenceOf(fwEnableRes)], extra: { listeningServices: listeningNow.map((i) => i.name) } });
|
|
375
|
+
} else if (fwOn === 'yes') controls.push({ control: 'host-firewall-enabled', option: fwEnableRes.path, applies: true, valueSource: view(fwEnableRes).via });
|
|
376
|
+
for (const res of [nixos('networking.firewall.allowedTCPPorts'), nixos('networking.firewall.allowedUDPPorts')]) {
|
|
377
|
+
const v = view(res);
|
|
378
|
+
const candidates = v.state === 'conditional' ? asList(v.possibleItems).concat(asList(v.definiteItems)) : globalPorts(v);
|
|
379
|
+
const ports = [...new Set(candidates)].filter((p) => SENSITIVE_PORTS.has(p));
|
|
380
|
+
for (const port of ports) {
|
|
381
|
+
addFinding('firewall-sensitive-port', { anchor: anchorOf(res), scope: `${res.path}:${port}`, state: v.state === 'known' ? 'active' : 'conditional', uncertain: !v.definite, evidence: [evidenceOf(res)], extra: { port, portService: SENSITIVE_PORTS.get(port), exposure: { enabled: 'unknown', listening: 'unknown', ingress: fwOff === 'yes' ? 'firewall-disabled' : 'open-in-firewall', reachable: 'unknown' } } });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const o of interfaceScoped) {
|
|
385
|
+
const iface = o.path.split('.')[3];
|
|
386
|
+
controls.push({ control: 'interface-scoped-ingress', option: o.path, interface: iface, ports: asList(view(o).value), applies: true });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── systemd services ──
|
|
390
|
+
const services = new Map();
|
|
391
|
+
for (const o of cfg.options) {
|
|
392
|
+
if (o.namespace !== 'nixos' || !o.path.startsWith('systemd.services.')) continue;
|
|
393
|
+
const m = /^systemd\.services\.([^.]+)\.(.+)$/.exec(o.path);
|
|
394
|
+
if (!m) { gaps.push({ kind: 'unparsed-service-path', option: o.path, reason: 'service name is not a plain identifier' }); continue; }
|
|
395
|
+
if (!services.has(m[1])) services.set(m[1], new Map());
|
|
396
|
+
services.get(m[1]).set(m[2], o);
|
|
397
|
+
}
|
|
398
|
+
for (const [name, opts2] of [...services].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
399
|
+
const get = (k) => opts2.get(k);
|
|
400
|
+
const enRes = get('enable');
|
|
401
|
+
const enJ = enRes ? judge(enRes, (x) => x === true) : 'yes';
|
|
402
|
+
if (enJ === 'no') { inventory.push({ kind: 'systemd-service', name, state: 'inactive', evidence: [evidenceOf(enRes)] }); continue; }
|
|
403
|
+
const anyCertain = [...opts2.values()].some((o) => o.sources.some((s) => s.role === 'winner'));
|
|
404
|
+
const sstate = enJ === 'conditional' || enJ === 'unknown' ? 'conditional' : anyCertain ? 'active' : 'conditional';
|
|
405
|
+
const sc = (k) => get(`serviceConfig.${k}`);
|
|
406
|
+
|
|
407
|
+
// identity
|
|
408
|
+
const userRes = sc('User');
|
|
409
|
+
const dynRes = sc('DynamicUser');
|
|
410
|
+
const userV = view(userRes);
|
|
411
|
+
const dynJ = dynRes ? judge(dynRes, (x) => x === true) : 'no';
|
|
412
|
+
let identity;
|
|
413
|
+
if (dynJ === 'yes') identity = { state: 'dynamic-user', user: null, evidence: evidenceOf(dynRes) };
|
|
414
|
+
else if (userRes && userV.state === 'known') identity = typeof userV.value === 'string' && userV.value !== 'root' && userV.value !== '0' ? { state: 'non-root', user: userV.value, evidence: evidenceOf(userRes) } : { state: 'root-explicit', user: userV.value, evidence: evidenceOf(userRes) };
|
|
415
|
+
else if (userRes || dynJ === 'conditional' || dynJ === 'unknown') identity = { state: 'conditional', user: null, evidence: userRes ? evidenceOf(userRes) : evidenceOf(dynRes) };
|
|
416
|
+
else identity = { state: 'root-default', user: 'root', evidence: null };
|
|
417
|
+
|
|
418
|
+
// ExecStart / script identity
|
|
419
|
+
const execRes = sc('ExecStart');
|
|
420
|
+
const scriptRes = get('script');
|
|
421
|
+
const execV = execRes ? view(execRes) : { state: 'unknown' };
|
|
422
|
+
let exec = { kind: 'none', command: null, elevated: false };
|
|
423
|
+
if (execV.state === 'known' && typeof execV.value === 'string') {
|
|
424
|
+
const m = /^([-@:+!]*)\s*(\S*)/.exec(execV.value.trim());
|
|
425
|
+
exec = { kind: 'exec-start', prefix: m[1], command: m[2] || null, elevated: /[+!]/.test(m[1]) };
|
|
426
|
+
} else if (execRes) exec = { kind: 'exec-start', command: null, dynamic: true, elevated: false };
|
|
427
|
+
else if (scriptRes) exec = { kind: 'generated-script', command: null, elevated: false };
|
|
428
|
+
|
|
429
|
+
// hardening controls recorded when they apply
|
|
430
|
+
const have = {};
|
|
431
|
+
const ctl = (k, pred) => { const r = sc(k); return r && judge(r, pred) === 'yes' ? r : null; };
|
|
432
|
+
have.noNewPrivs = ctl('NoNewPrivileges', (x) => x === true);
|
|
433
|
+
have.protectSystem = ctl('ProtectSystem', (x) => x === true || x === 'strict' || x === 'full');
|
|
434
|
+
have.privateTmp = ctl('PrivateTmp', (x) => x === true);
|
|
435
|
+
have.privateDevices = ctl('PrivateDevices', (x) => x === true);
|
|
436
|
+
have.capsRestricted = (() => { const r = sc('CapabilityBoundingSet'); const v = r && view(r); return v && v.state === 'known' && asList(v.value).every((c) => typeof c === 'string' && !DANGEROUS_CAPS.has(c.replace(/^~/, '')) || String(c).startsWith('~')) ? r : null; })();
|
|
437
|
+
const applied = Object.entries(have).filter(([, r]) => r).map(([k, r]) => ({ control: k, option: r.path, applies: sstate !== 'inactive' }));
|
|
438
|
+
for (const c of applied) controls.push({ ...c, service: name });
|
|
439
|
+
if (identity.state === 'non-root' || identity.state === 'dynamic-user') controls.push({ control: 'non-root-identity', service: name, user: identity.user, option: (userRes || dynRes).path, applies: true });
|
|
440
|
+
|
|
441
|
+
inventory.push({ kind: 'systemd-service', name, state: sstate, identity: { state: identity.state, user: identity.user }, exec, hardeningControls: applied.map((c) => c.control), evidence: [identity.evidence, ...[enRes].filter(Boolean).map(evidenceOf)].filter(Boolean) });
|
|
442
|
+
|
|
443
|
+
const st = sstate === 'conditional' ? 'conditional' : 'active';
|
|
444
|
+
const runnable = exec.kind !== 'none';
|
|
445
|
+
|
|
446
|
+
// root identity
|
|
447
|
+
if (runnable && (identity.state === 'root-explicit' || identity.state === 'root-default')) {
|
|
448
|
+
const strong = applied.length >= 3;
|
|
449
|
+
const anchor = identity.evidence && userRes ? anchorOf(userRes) : anchorOf(execRes || scriptRes || [...opts2.values()][0]);
|
|
450
|
+
addFinding('service-runs-as-root', { severity: strong ? 'low' : identity.state === 'root-default' ? 'low' : 'medium', anchor, scope: `systemd.services.${name}`, state: st, uncertain: identity.state === 'root-explicit' && !view(userRes).definite, evidence: [identity.evidence, ...(execRes ? [evidenceOf(execRes)] : [])].filter(Boolean), extra: { identitySource: identity.state === 'root-default' ? 'systemd-default' : 'explicit', hardeningControls: applied.map((c) => c.control) }, mitigations: strong ? [{ kind: 'hardening-controls', controls: applied.map((c) => c.control) }] : [] });
|
|
451
|
+
entryPoints.push({ id: `root-service:${name}`, kind: 'root-service', state: st, privilege: 'root', exec, evidence: [identity.evidence].filter(Boolean) });
|
|
452
|
+
}
|
|
453
|
+
if (exec.elevated) {
|
|
454
|
+
addFinding('service-exec-elevated', { anchor: anchorOf(execRes), scope: `systemd.services.${name}`, state: st, uncertain: !execV.definite, evidence: [evidenceOf(execRes)], extra: { execPrefix: exec.prefix } });
|
|
455
|
+
entryPoints.push({ id: `elevated-exec:${name}`, kind: 'elevated-exec', state: st, privilege: 'root', exec, evidence: [evidenceOf(execRes)] });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// capabilities / devices / filesystem
|
|
459
|
+
for (const key of ['AmbientCapabilities', 'CapabilityBoundingSet']) {
|
|
460
|
+
const r = sc(key);
|
|
461
|
+
if (!r) continue;
|
|
462
|
+
const v = view(r);
|
|
463
|
+
const items = v.state === 'known' ? asList(v.value) : v.state === 'conditional' ? asList(v.definiteItems) : [];
|
|
464
|
+
const bad = items.filter((c) => typeof c === 'string' && !c.startsWith('~') && DANGEROUS_CAPS.has(c));
|
|
465
|
+
// a bounding set only limits; an ambient set grants, so only Ambient is a hard finding for non-root-looking services
|
|
466
|
+
if (bad.length && (key === 'AmbientCapabilities' || identity.state !== 'root-default')) {
|
|
467
|
+
addFinding('systemd-dangerous-capability', { severity: key === 'AmbientCapabilities' ? 'high' : 'medium', anchor: anchorOf(r), scope: `systemd.services.${name}.${key}`, state: st === 'conditional' || v.state === 'conditional' ? 'conditional' : 'active', uncertain: !v.definite, evidence: [evidenceOf(r)], extra: { capabilities: bad } });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const dev = sc('DeviceAllow');
|
|
471
|
+
if (dev) {
|
|
472
|
+
const v = view(dev);
|
|
473
|
+
const items = v.state === 'known' ? (Array.isArray(v.value) ? v.value : [v.value]) : [];
|
|
474
|
+
const bad = items.filter((d) => typeof d === 'string' && /^(\/dev\/(k?mem|port)\b|char-\*|block-\*)(\s+\S*w\S*)?/.test(d.trim()) && /\bw/.test(d.split(/\s+/)[1] || 'rw'));
|
|
475
|
+
if (bad.length) addFinding('systemd-device-access', { anchor: anchorOf(dev), scope: `systemd.services.${name}.DeviceAllow`, state: st, uncertain: !v.definite, evidence: [evidenceOf(dev)], extra: { devices: bad } });
|
|
476
|
+
}
|
|
477
|
+
const relaxed = [];
|
|
478
|
+
const psR = sc('ProtectSystem'); if (psR && judge(psR, (x) => x === false) === 'yes') relaxed.push(psR);
|
|
479
|
+
const ptR = sc('PrivateTmp'); if (ptR && judge(ptR, (x) => x === false) === 'yes') relaxed.push(ptR);
|
|
480
|
+
const rwR = sc('ReadWritePaths'); if (rwR && judge(rwR, (x) => (Array.isArray(x) ? x : [x]).some((p) => p === '/' || p === '/etc' || p === '/usr')) === 'yes') relaxed.push(rwR);
|
|
481
|
+
if (relaxed.length) addFinding('systemd-filesystem-relaxed', { anchor: anchorOf(relaxed[0]), scope: `systemd.services.${name}`, state: st, uncertain: relaxed.some((r) => !view(r).definite), evidence: relaxed.map(evidenceOf) });
|
|
482
|
+
const nnp = sc('NoNewPrivileges');
|
|
483
|
+
if (nnp && judge(nnp, (x) => x === false) === 'yes') addFinding('systemd-no-new-privileges-off', { anchor: anchorOf(nnp), scope: `systemd.services.${name}`, state: st, uncertain: !view(nnp).definite, evidence: [evidenceOf(nnp)] });
|
|
484
|
+
|
|
485
|
+
// TLS verification
|
|
486
|
+
for (const [envName, pred] of TLS_OFF) {
|
|
487
|
+
const r = get(`environment.${envName}`);
|
|
488
|
+
if (r && judge(r, (x) => typeof x === 'string' && pred(x)) === 'yes') addFinding('tls-verification-disabled', { anchor: anchorOf(r), scope: `systemd.services.${name}.environment.${envName}`, state: st, uncertain: !view(r).definite, evidence: [evidenceOf(r)], extra: { appFlowProven: false } });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── OCI containers and NixOS containers (declarations only) ──
|
|
493
|
+
const oci = new Map();
|
|
494
|
+
for (const o of cfg.options) {
|
|
495
|
+
const m = o.namespace === 'nixos' ? /^virtualisation\.oci-containers\.containers\.([^.]+)\.(.+)$/.exec(o.path) : null;
|
|
496
|
+
if (!m) continue;
|
|
497
|
+
if (!oci.has(m[1])) oci.set(m[1], new Map());
|
|
498
|
+
oci.get(m[1]).set(m[2], o);
|
|
499
|
+
}
|
|
500
|
+
for (const [name, opts2] of [...oci].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
501
|
+
const strs = (k) => {
|
|
502
|
+
const r = opts2.get(k);
|
|
503
|
+
const v = r && view(r);
|
|
504
|
+
if (!v) return [];
|
|
505
|
+
if (v.state === 'known') return asList(v.value).filter((x) => typeof x === 'string');
|
|
506
|
+
if (v.state === 'conditional') return v.possible.flatMap((p) => asList(p)).concat(asList(v.possibleItems)).filter((x) => typeof x === 'string');
|
|
507
|
+
return [];
|
|
508
|
+
};
|
|
509
|
+
const condOpt = ['extraOptions', 'volumes'].some((k) => opts2.get(k) && view(opts2.get(k)).state === 'conditional');
|
|
510
|
+
const extraOpts = strs('extraOptions');
|
|
511
|
+
const vols = strs('volumes');
|
|
512
|
+
const ports = strs('ports');
|
|
513
|
+
const imageRes = opts2.get('image');
|
|
514
|
+
const st = !condOpt && [...opts2.values()].some((o) => o.sources.some((s) => s.role === 'winner')) ? 'active' : 'conditional';
|
|
515
|
+
inventory.push({ kind: 'oci-container', name, state: st, image: imageRes && view(imageRes).state === 'known' ? view(imageRes).value : null, publishedPorts: ports, imageLayerScan: 'not-performed', evidence: [...opts2.values()].map(evidenceOf) });
|
|
516
|
+
const risky = [];
|
|
517
|
+
if (extraOpts.some((o) => /^--privileged(=true)?$/.test(o))) risky.push('--privileged');
|
|
518
|
+
for (const o of extraOpts) { const m = /^--cap-add[= ](.+)$/.exec(o); if (m && (m[1] === 'ALL' || m[1] === 'SYS_ADMIN' || m[1] === 'CAP_SYS_ADMIN')) risky.push(`--cap-add=${m[1]}`); }
|
|
519
|
+
if (vols.some((v) => /^\/(var\/)?run\/(docker|podman)\.sock(:|$)|^\/var\/run\/docker\.sock(:|$)/.test(v))) risky.push('container runtime socket mount');
|
|
520
|
+
const anyRes = opts2.get('extraOptions') || opts2.get('volumes');
|
|
521
|
+
if (risky.length) {
|
|
522
|
+
addFinding('container-privileged', { anchor: anchorOf(anyRes), scope: `virtualisation.oci-containers.containers.${name}`, state: st, uncertain: !view(anyRes).definite, evidence: [...opts2.values()].filter((o) => ['extraOptions', 'volumes'].includes(o.path.split('.').slice(4).join('.'))).map(evidenceOf), extra: { indicators: risky, imageLayerScan: 'not-performed' } });
|
|
523
|
+
entryPoints.push({ id: `privileged-container:${name}`, kind: 'privileged-container', state: st, privilege: 'host', evidence: [evidenceOf(anyRes)] });
|
|
524
|
+
}
|
|
525
|
+
if (extraOpts.some((o) => /^--(net|network)[= ]host$/.test(o))) addFinding('container-host-network', { anchor: anchorOf(opts2.get('extraOptions')), scope: `virtualisation.oci-containers.containers.${name}`, state: st, uncertain: !view(opts2.get('extraOptions')).definite, evidence: [evidenceOf(opts2.get('extraOptions'))], extra: { imageLayerScan: 'not-performed' } });
|
|
526
|
+
}
|
|
527
|
+
const ncont = new Set();
|
|
528
|
+
for (const o of cfg.options) {
|
|
529
|
+
const m = o.namespace === 'nixos' ? /^containers\.([^.]+)\.(.+)$/.exec(o.path) : null;
|
|
530
|
+
if (m) ncont.add(m[1]);
|
|
531
|
+
}
|
|
532
|
+
for (const name of [...ncont].sort()) {
|
|
533
|
+
const pn = cfg.options.find((o) => o.path === `containers.${name}.privateNetwork`);
|
|
534
|
+
inventory.push({ kind: 'nixos-container', name, state: 'declared', privateNetwork: pn && view(pn).state === 'known' ? view(pn).value : 'unknown', imageLayerScan: 'not-applicable', evidence: pn ? [evidenceOf(pn)] : [] });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── coverage gaps and reconciliation ──
|
|
538
|
+
for (const u of cfg.unknownOptions) gaps.push({ kind: 'unknown-option', option: u.path, reason: 'not a known option in a cataloged namespace; it may be a misspelling or removed option and may not have taken effect' });
|
|
539
|
+
for (const g of cfg.unresolved) if (!['home-manager-function-module'].includes(g.kind)) gaps.push({ kind: g.kind, detail: g.detail, file: g.file });
|
|
540
|
+
for (const t of cfg.truncated) gaps.push({ kind: 'truncated', budget: t.budget });
|
|
541
|
+
const count = (list, s) => list.filter((i) => i.state === s).length;
|
|
542
|
+
const svc = inventory.filter((i) => i.kind === 'declared-service' || i.kind === 'systemd-service');
|
|
543
|
+
const reconciliation = {
|
|
544
|
+
services: { total: svc.length, active: count(svc, 'active'), conditional: count(svc, 'conditional'), inactive: count(svc, 'inactive'), unknown: count(svc, 'unknown') },
|
|
545
|
+
entryPoints: { total: entryPoints.length, active: count(entryPoints, 'active'), conditional: count(entryPoints, 'conditional') },
|
|
546
|
+
unanalyzed: gaps,
|
|
547
|
+
completeness: cfg.completeness === 'complete' && !gaps.length ? 'complete' : 'partial',
|
|
548
|
+
findingsByEntryPoint: Object.fromEntries(entryPoints.map((e) => {
|
|
549
|
+
const rules = ENTRY_RULES[e.kind] || [];
|
|
550
|
+
const name = e.id.includes(':') ? e.id.slice(e.id.indexOf(':') + 1) : null;
|
|
551
|
+
return [e.id, findings.filter((f) => rules.includes(f.rule) && (!name || f.subject.includes(name))).map((f) => f.id)];
|
|
552
|
+
})),
|
|
553
|
+
};
|
|
554
|
+
reconciliation.consistent = reconciliation.services.total === reconciliation.services.active + reconciliation.services.conditional + reconciliation.services.inactive + reconciliation.services.unknown
|
|
555
|
+
&& reconciliation.entryPoints.total === reconciliation.entryPoints.active + reconciliation.entryPoints.conditional;
|
|
556
|
+
|
|
557
|
+
findings.sort((a, b) => a.id.localeCompare(b.id));
|
|
558
|
+
return {
|
|
559
|
+
kind: 'nixos-hardening-report', version: 1, rulesetVersion: HARDENING_RULESET_VERSION,
|
|
560
|
+
target: cfg.target, catalog: cfg.catalog, release: cfg.target.release && normalizeRelease(cfg.target.release),
|
|
561
|
+
findings, controls, inventory, privilegedEntryPoints: entryPoints, authzEvidence: authz, attackPaths, reconciliation,
|
|
562
|
+
scope: { imageLayerScan: 'not-performed', internetReachability: 'unknown', note: 'host firewall is modelled; external network topology is not' },
|
|
563
|
+
};
|
|
564
|
+
}
|