@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +16 -0
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/cred-stores.js +471 -0
- package/lib/collectors/hardening.js +308 -0
- package/manifest.json +44 -44
- package/package.json +1 -1
- package/sbom.cdx.json +44 -14
|
@@ -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 };
|