@blamejs/exceptd-skills 0.13.37 → 0.13.39

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.
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/collectors/hardening.js
5
+ *
6
+ * Companion collector for the `hardening` playbook. Linux-only:
7
+ * reads `/proc/sys/kernel/*`, `/proc/cmdline`,
8
+ * `/sys/kernel/security/lockdown`, and `/etc/ssh/sshd_config` to
9
+ * flip deterministic indicators. On non-Linux platforms the
10
+ * precondition fails and the collector emits an empty submission.
11
+ *
12
+ * Interface: see lib/collectors/README.md
13
+ */
14
+
15
+ const fs = require("node:fs");
16
+ const path = require("node:path");
17
+
18
+ const COLLECTOR_ID = "hardening";
19
+
20
+ function readSysctl(p) {
21
+ try {
22
+ const s = fs.readFileSync(p, "utf8").trim();
23
+ return s;
24
+ } catch { return null; }
25
+ }
26
+
27
+ function readFileSafe(p, max = 256 * 1024) {
28
+ try {
29
+ const st = fs.statSync(p);
30
+ if (st.size > max) return null;
31
+ return fs.readFileSync(p, "utf8");
32
+ } catch { return null; }
33
+ }
34
+
35
+ // Expand the base sshd_config into the effective directive stream by
36
+ // inlining `Include <glob>` directives at their textual position.
37
+ // Mirrors OpenSSH's parse order: first-match-wins, and the first match
38
+ // can come from a drop-in file when `Include` appears earlier in the
39
+ // base config than the directive it sets. Drop-in files within an
40
+ // Include are processed in lexical order (matching OpenSSH glob).
41
+ function expandSshdConfig(baseContent, configDPath) {
42
+ if (!baseContent) return "";
43
+ const out = [];
44
+ for (const raw of baseContent.split(/\r?\n/)) {
45
+ const stripped = raw.replace(/#.*$/, "").trim();
46
+ const m = stripped.match(/^Include\s+(\S+)/i);
47
+ if (!m) { out.push(raw); continue; }
48
+ const glob = m[1];
49
+ // Resolve the include glob — only handle the common
50
+ // `<dir>/*.conf` form; other shapes fall back to no-op.
51
+ let dir = null;
52
+ if (glob.endsWith("/sshd_config.d/*.conf")) {
53
+ // The canonical sshd-config drop-in directory. Honour the
54
+ // path override (tests / chroot / sshd_config.d outside the
55
+ // default location).
56
+ dir = configDPath;
57
+ } else {
58
+ const dirMatch = glob.match(/^(.*)\/\*\.conf$/);
59
+ if (dirMatch) dir = dirMatch[1];
60
+ }
61
+ if (!dir) { out.push(raw); continue; }
62
+ let entries;
63
+ try { entries = fs.readdirSync(dir).filter(e => /\.conf$/.test(e)).sort(); }
64
+ catch { out.push(raw); continue; }
65
+ for (const e of entries) {
66
+ const c = readFileSafe(path.join(dir, e));
67
+ if (c == null) continue;
68
+ out.push(`# === drop-in: ${e} ===`);
69
+ out.push(c);
70
+ }
71
+ }
72
+ return out.join("\n");
73
+ }
74
+
75
+ function parseSshdEffective(content) {
76
+ // Best-effort: scan uncommented `PermitRootLogin <value>` and
77
+ // `PasswordAuthentication <value>` lines. sshd_config is parsed
78
+ // first-match-wins for most directives.
79
+ if (!content) return { permitRootLogin: null, passwordAuth: null };
80
+ const out = { permitRootLogin: null, passwordAuth: null };
81
+ for (const raw of content.split(/\r?\n/)) {
82
+ const line = raw.replace(/#.*$/, "").trim();
83
+ if (!line) continue;
84
+ const m1 = line.match(/^PermitRootLogin\s+(\S+)/i);
85
+ if (m1 && out.permitRootLogin == null) out.permitRootLogin = m1[1].toLowerCase();
86
+ const m2 = line.match(/^PasswordAuthentication\s+(\S+)/i);
87
+ if (m2 && out.passwordAuth == null) out.passwordAuth = m2[1].toLowerCase();
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
93
+ const errors = [];
94
+ const startTime = Date.now();
95
+ const root = path.resolve(cwd);
96
+ // Path-override hooks for tests: caller can pass args.paths to
97
+ // redirect /proc / /sys / /etc reads to a synthetic tempdir
98
+ // mirroring the real layout. Without overrides the collector
99
+ // reads the live host paths.
100
+ const paths = args.paths || {};
101
+ const P = {
102
+ kptrRestrict: paths.kptrRestrict || "/proc/sys/kernel/kptr_restrict",
103
+ unprivUserns: paths.unprivUserns || "/proc/sys/kernel/unprivileged_userns_clone",
104
+ unprivBpf: paths.unprivBpf || "/proc/sys/kernel/unprivileged_bpf_disabled",
105
+ yamaPtrace: paths.yamaPtrace || "/proc/sys/kernel/yama/ptrace_scope",
106
+ suidDumpable: paths.suidDumpable || "/proc/sys/fs/suid_dumpable",
107
+ cmdline: paths.cmdline || "/proc/cmdline",
108
+ lockdown: paths.lockdown || "/sys/kernel/security/lockdown",
109
+ sshdConfig: paths.sshdConfig || "/etc/ssh/sshd_config",
110
+ sshdConfigD: paths.sshdConfigD || "/etc/ssh/sshd_config.d",
111
+ kallsyms: paths.kallsyms || "/proc/kallsyms",
112
+ };
113
+ // Force-linux switch lets tests exercise the Linux code path even
114
+ // when running on win32 / darwin. Without it, the platform gate
115
+ // remains the source of truth for whether the collector runs.
116
+ const isLinux = args.forceLinux === true || process.platform === "linux";
117
+
118
+ if (!isLinux) {
119
+ return {
120
+ precondition_checks: { "linux-platform": false },
121
+ artifacts: {
122
+ "sysctl-kernel-hardening": {
123
+ value: "skipped — non-Linux platform",
124
+ captured: false,
125
+ reason: `process.platform=${process.platform} (linux required)`,
126
+ },
127
+ "kernel-cmdline": {
128
+ value: "skipped — non-Linux platform",
129
+ captured: false,
130
+ reason: `process.platform=${process.platform} (linux required)`,
131
+ },
132
+ "sshd-config": {
133
+ value: "skipped — non-Linux platform",
134
+ captured: false,
135
+ reason: `process.platform=${process.platform} (linux required)`,
136
+ },
137
+ "kernel-lockdown": {
138
+ value: "skipped — non-Linux platform",
139
+ captured: false,
140
+ reason: `process.platform=${process.platform} (linux required)`,
141
+ },
142
+ },
143
+ signal_overrides: {},
144
+ collector_meta: {
145
+ collector_id: COLLECTOR_ID,
146
+ collector_version: "2026-05-20",
147
+ platform: process.platform,
148
+ captured_at: new Date().toISOString(),
149
+ cwd: root,
150
+ duration_ms: Date.now() - startTime,
151
+ },
152
+ collector_errors: errors,
153
+ };
154
+ }
155
+
156
+ // Sysctl reads. `null` means the sysctl path didn't exist (older
157
+ // kernel / non-standard build).
158
+ const kptrRestrict = readSysctl(P.kptrRestrict);
159
+ const unprivUserns = readSysctl(P.unprivUserns);
160
+ const unprivBpf = readSysctl(P.unprivBpf);
161
+ const yamaPtrace = readSysctl(P.yamaPtrace);
162
+ const suidDumpable = readSysctl(P.suidDumpable);
163
+ const cmdline = readFileSafe(P.cmdline) || "";
164
+ const lockdown = readSysctl(P.lockdown) || "";
165
+
166
+ // sshd_config: expand Include directives in-place so the
167
+ // first-match-wins parse honours OpenSSH's effective directive
168
+ // order. On Debian/Ubuntu the default sshd_config begins with
169
+ // `Include /etc/ssh/sshd_config.d/*.conf` — drop-in values take
170
+ // precedence over the base file's later lines.
171
+ const sshdBase = readFileSafe(P.sshdConfig);
172
+ const sshdContent = sshdBase ? expandSshdConfig(sshdBase, P.sshdConfigD) : null;
173
+ const sshdParsed = parseSshdEffective(sshdContent);
174
+
175
+ // Indicator predicates. Each sysctl-derived indicator emits a
176
+ // verdict ONLY when the underlying sysctl was readable; unreadable
177
+ // sysctls (e.g. masked /proc in a constrained container, kernel
178
+ // built without that knob) leave the indicator unflipped so the
179
+ // runner returns inconclusive rather than forging a "hardened"
180
+ // miss without evidence.
181
+ function fromSysctl(value, hitWhen) {
182
+ if (value == null) return undefined; // unreadable → inconclusive
183
+ return value === hitWhen ? "hit" : "miss";
184
+ }
185
+
186
+ const signal_overrides = {};
187
+ const kptrSig = fromSysctl(kptrRestrict, "0");
188
+ if (kptrSig !== undefined) signal_overrides["kptr-restrict-disabled"] = kptrSig;
189
+ const usernsSig = fromSysctl(unprivUserns, "1");
190
+ if (usernsSig !== undefined) signal_overrides["unprivileged-userns-enabled"] = usernsSig;
191
+ const bpfSig = fromSysctl(unprivBpf, "0");
192
+ if (bpfSig !== undefined) signal_overrides["unprivileged-bpf-allowed"] = bpfSig;
193
+ const yamaSig = fromSysctl(yamaPtrace, "0");
194
+ if (yamaSig !== undefined) signal_overrides["yama-ptrace-permissive"] = yamaSig;
195
+
196
+ // /proc/cmdline derives kaslr / mitigations / lockdown=. If we
197
+ // couldn't read it at all, those three indicators stay unflipped
198
+ // (inconclusive) rather than asserting an absent string.
199
+ if (cmdline) {
200
+ const kaslrDisabled = /\bnokaslr\b/.test(cmdline) || /\bkaslr=off\b/.test(cmdline);
201
+ const mitigationsOff = /\bmitigations=off\b/.test(cmdline);
202
+ signal_overrides["kaslr-disabled-at-boot"] = kaslrDisabled ? "hit" : "miss";
203
+ signal_overrides["mitigations-off"] = mitigationsOff ? "hit" : "miss";
204
+ }
205
+
206
+ // kernel-lockdown-none: the file shows `[none]` OR is absent and
207
+ // /proc/cmdline carries no lockdown= parameter. When both the
208
+ // lockdown file AND /proc/cmdline are unreadable, leave the
209
+ // indicator unflipped.
210
+ if (lockdown || cmdline) {
211
+ const lockdownShowsNone = /\[none\]/.test(lockdown);
212
+ const lockdownCmdline = /\blockdown=(?:integrity|confidentiality)\b/.test(cmdline);
213
+ const lockdownNoneHit =
214
+ (lockdown && lockdownShowsNone) ||
215
+ (!lockdown && cmdline && !lockdownCmdline);
216
+ signal_overrides["kernel-lockdown-none"] = lockdownNoneHit ? "hit" : "miss";
217
+ }
218
+
219
+ // sshd-permitrootlogin-yes: emit a verdict only when sshd_config
220
+ // was readable. Missing config (no SSH server) → unflipped.
221
+ let sshdRootHit = false;
222
+ if (sshdContent !== null) {
223
+ sshdRootHit =
224
+ sshdParsed.permitRootLogin === "yes" ||
225
+ sshdParsed.permitRootLogin === "without-password";
226
+ signal_overrides["sshd-permitrootlogin-yes"] = sshdRootHit ? "hit" : "miss";
227
+ }
228
+
229
+ const kptrHit = kptrSig === "hit";
230
+
231
+ // Per-indicator __fp_checks attestation. The collector attests
232
+ // ONLY the checks it actually performed; operator-judgement /
233
+ // network-required FP checks remain unsatisfied so the runner
234
+ // honestly downgrades to inconclusive.
235
+ //
236
+ // kptr-restrict-disabled:
237
+ // [0] kdump / perf debug-session runbook — operator judgement
238
+ // [1] /proc/kallsyms zero-leakage cross-check — collector CAN
239
+ // attest (read first line as unprivileged user).
240
+ // yama-ptrace-permissive:
241
+ // [0] MAC enforcement (AppArmor / SELinux) — operator
242
+ // [1] container observability — operator
243
+ // [2] single-tenant dev VM — operator
244
+ // kaslr-disabled-at-boot:
245
+ // [0] kdump runbook — operator
246
+ // [1] dmesg KASLR offsets — collector cannot read dmesg
247
+ // without root on most distros (dmesg-restrict=1).
248
+ // mitigations-off:
249
+ // [0] HPC/benchmark exemption — operator
250
+ // [1] single-tenant — operator
251
+ //
252
+ // For kptr-restrict-disabled the kallsyms cross-check is the only
253
+ // FP-check the collector can attest; we attest index 1 when we
254
+ // observed the kallsyms first line carries non-zero hex.
255
+ if (kptrHit) {
256
+ let kallsymsLeaks = false;
257
+ try {
258
+ const head = fs.readFileSync(P.kallsyms, { encoding: "utf8" }).split(/\r?\n/, 1)[0] || "";
259
+ const tok = head.split(/\s+/, 1)[0] || "";
260
+ // Non-zero addr → kernel pointers are leaked → indicator is real.
261
+ kallsymsLeaks = /[1-9a-f]/i.test(tok);
262
+ } catch { /* can't read */ }
263
+ if (kallsymsLeaks) {
264
+ signal_overrides["kptr-restrict-disabled__fp_checks"] = { "1": true };
265
+ }
266
+ }
267
+
268
+ const artifacts = {
269
+ "sysctl-kernel-hardening": {
270
+ value: [
271
+ `kptr_restrict=${kptrRestrict ?? "(absent)"}`,
272
+ `unprivileged_userns_clone=${unprivUserns ?? "(absent)"}`,
273
+ `unprivileged_bpf_disabled=${unprivBpf ?? "(absent)"}`,
274
+ `yama/ptrace_scope=${yamaPtrace ?? "(absent)"}`,
275
+ `fs.suid_dumpable=${suidDumpable ?? "(absent)"}`,
276
+ ].join("; "),
277
+ captured: true,
278
+ },
279
+ "kernel-cmdline": cmdline
280
+ ? { value: cmdline.trim(), captured: true }
281
+ : { value: "(/proc/cmdline unreadable)", captured: false, reason: "could not read /proc/cmdline" },
282
+ "kernel-lockdown": lockdown
283
+ ? { value: lockdown, captured: true }
284
+ : { value: "/sys/kernel/security/lockdown absent — lockdown LSM not loaded", captured: true },
285
+ "sshd-config": sshdContent
286
+ ? { value: `PermitRootLogin=${sshdParsed.permitRootLogin ?? "(unset; sshd default applies)"}; PasswordAuthentication=${sshdParsed.passwordAuth ?? "(unset)"}`, captured: true }
287
+ : { value: "/etc/ssh/sshd_config unreadable", captured: false, reason: "/etc/ssh/sshd_config missing or inaccessible" },
288
+ };
289
+
290
+ return {
291
+ precondition_checks: {
292
+ "linux-platform": true,
293
+ },
294
+ artifacts,
295
+ signal_overrides,
296
+ collector_meta: {
297
+ collector_id: COLLECTOR_ID,
298
+ collector_version: "2026-05-20",
299
+ platform: process.platform,
300
+ captured_at: new Date().toISOString(),
301
+ cwd: root,
302
+ duration_ms: Date.now() - startTime,
303
+ },
304
+ collector_errors: errors,
305
+ };
306
+ }
307
+
308
+ module.exports = { playbook_id: COLLECTOR_ID, collect };