@blamejs/exceptd-skills 0.13.33 → 0.13.35

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,403 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/collectors/containers.js
5
+ *
6
+ * Companion collector for the `containers` playbook. Walks the cwd
7
+ * for Dockerfile / Containerfile, docker-compose, and k8s manifest
8
+ * files; applies the catalogued indicator predicates against their
9
+ * contents.
10
+ *
11
+ * YAML parsing strategy: heuristic line-scanning, not a full YAML
12
+ * parser. The catalogued indicators (privileged: true, hostNetwork:
13
+ * true, runAsUser: 0, etc.) are well-known text patterns whose
14
+ * misuse is unambiguous at the line level. False positives are rare
15
+ * — e.g. `# privileged: true` in a comment would match, but those
16
+ * are also worth surfacing.
17
+ *
18
+ * Interface: see lib/collectors/README.md
19
+ */
20
+
21
+ const fs = require("node:fs");
22
+ const path = require("node:path");
23
+
24
+ const COLLECTOR_ID = "containers";
25
+
26
+ const DEFAULT_MAX_DEPTH = 6;
27
+ const DEFAULT_EXCLUDES = new Set([
28
+ "node_modules", ".git", "dist", "build", "out",
29
+ ".venv", "venv", "__pycache__", ".pytest_cache",
30
+ "target", ".idea", ".vscode",
31
+ ]);
32
+
33
+ const DOCKERFILE_NAMES = new Set(["Dockerfile", "Containerfile"]);
34
+ const DOCKERFILE_EXTS = new Set([".dockerfile", ".containerfile"]);
35
+ const COMPOSE_NAMES = new Set([
36
+ "docker-compose.yml", "docker-compose.yaml",
37
+ "compose.yml", "compose.yaml",
38
+ ]);
39
+ const COMPOSE_PREFIX = "docker-compose."; // docker-compose.override.yml etc.
40
+
41
+ function walkTree(root, opts = {}) {
42
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
43
+ const excludes = opts.excludes ?? DEFAULT_EXCLUDES;
44
+ const out = [];
45
+ const seen = new Set();
46
+ function walk(dir, depth) {
47
+ if (depth > maxDepth) return;
48
+ let entries;
49
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
50
+ for (const entry of entries) {
51
+ if (excludes.has(entry.name)) continue;
52
+ const full = path.join(dir, entry.name);
53
+ let real;
54
+ try { real = fs.realpathSync(full); } catch { continue; }
55
+ if (seen.has(real)) continue;
56
+ seen.add(real);
57
+ if (entry.isDirectory()) walk(full, depth + 1);
58
+ else if (entry.isFile()) out.push({ full, rel: path.relative(root, full), name: entry.name });
59
+ }
60
+ }
61
+ walk(root, 0);
62
+ return out;
63
+ }
64
+
65
+ function classify(file) {
66
+ const name = file.name;
67
+ const ext = path.extname(name).toLowerCase();
68
+ const lower = name.toLowerCase();
69
+ const isDockerfile =
70
+ DOCKERFILE_NAMES.has(name) ||
71
+ name.endsWith(".Dockerfile") || name.endsWith(".dockerfile") ||
72
+ DOCKERFILE_EXTS.has(ext) ||
73
+ lower === "dockerfile" || lower.endsWith(".dockerfile");
74
+ const isCompose =
75
+ COMPOSE_NAMES.has(name) ||
76
+ (lower.startsWith(COMPOSE_PREFIX) && (ext === ".yml" || ext === ".yaml"));
77
+ const isYaml = ext === ".yml" || ext === ".yaml";
78
+ return { isDockerfile, isCompose, isYaml };
79
+ }
80
+
81
+ function readSafe(full, max = 512 * 1024) {
82
+ try {
83
+ const s = fs.statSync(full);
84
+ if (s.size > max) return null;
85
+ return fs.readFileSync(full, "utf8");
86
+ } catch { return null; }
87
+ }
88
+
89
+ /**
90
+ * Recognise a YAML document as a k8s resource by `apiVersion: ...` +
91
+ * `kind: ...` lines. Doesn't require full parsing — the two lines
92
+ * appear at top-level indent.
93
+ */
94
+ function looksLikeK8sManifest(content) {
95
+ if (!/^apiVersion:\s+\S/m.test(content)) return false;
96
+ if (!/^kind:\s+\S/m.test(content)) return false;
97
+ return true;
98
+ }
99
+
100
+ function extractKind(content) {
101
+ const m = content.match(/^kind:\s+([A-Za-z][A-Za-z0-9]*)/m);
102
+ return m ? m[1] : null;
103
+ }
104
+
105
+ /**
106
+ * Dockerfile pattern matchers. Returns { id, hits: [{line, snippet}] }.
107
+ */
108
+ function scanDockerfile(content, rel) {
109
+ const lines = content.split(/\r?\n/);
110
+ const hits = {
111
+ "dockerfile-from-latest": [],
112
+ "dockerfile-no-digest-pin": [],
113
+ "dockerfile-runs-as-root": [],
114
+ "dockerfile-curl-pipe-bash": [],
115
+ };
116
+
117
+ let sawNonRootUser = false;
118
+ let sawAnyUser = false;
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const line = lines[i];
121
+ const trimmed = line.trim();
122
+ if (!trimmed || trimmed.startsWith("#")) continue;
123
+
124
+ // FROM <image>:latest OR FROM <image> (no tag) → from-latest
125
+ // FROM <image>:<tag> (no @sha256:digest) → no-digest-pin
126
+ const fromMatch = trimmed.match(/^FROM\s+(\S+)(?:\s+AS\s+\S+)?/i);
127
+ if (fromMatch) {
128
+ const ref = fromMatch[1];
129
+ // scratch is a special base; skip both checks.
130
+ if (ref !== "scratch") {
131
+ const hasDigest = /@sha256:[0-9a-f]{64}/i.test(ref);
132
+ if (!hasDigest) {
133
+ hits["dockerfile-no-digest-pin"].push({ file: rel, line: i + 1, snippet: trimmed.slice(0, 120) });
134
+ }
135
+ // latest tag check: either explicit :latest OR no tag at all
136
+ // (Docker defaults to :latest when omitted).
137
+ const tagMatch = ref.match(/:([^@]+)(?:@|$)/);
138
+ const tag = tagMatch ? tagMatch[1] : null;
139
+ if (tag === "latest" || tag === null) {
140
+ hits["dockerfile-from-latest"].push({ file: rel, line: i + 1, snippet: trimmed.slice(0, 120) });
141
+ }
142
+ }
143
+ continue;
144
+ }
145
+
146
+ // USER directive — looks for explicit non-root user.
147
+ // Recognises USER <uid>, USER <uid>:<gid>, USER <name>, USER <name>:<group>.
148
+ // Root forms (all count as root): "root", "0", "root:<anything>",
149
+ // "0:<anything>". Any other UID/name is non-root.
150
+ const userMatch = trimmed.match(/^USER\s+(\S+)/i);
151
+ if (userMatch) {
152
+ sawAnyUser = true;
153
+ const u = userMatch[1];
154
+ const userPart = u.split(":")[0]; // strip optional :group
155
+ if (userPart !== "0" && userPart !== "root") sawNonRootUser = true;
156
+ continue;
157
+ }
158
+
159
+ // curl|wget | sh|bash pattern
160
+ if (
161
+ /\b(?:curl|wget)\b[^|]*\|\s*(?:sh|bash|zsh)\b/.test(trimmed) ||
162
+ /\b(?:curl|wget)\b[^&|;]*\s+&&\s+(?:sh|bash)\b/.test(trimmed)
163
+ ) {
164
+ hits["dockerfile-curl-pipe-bash"].push({ file: rel, line: i + 1, snippet: trimmed.slice(0, 120) });
165
+ }
166
+ }
167
+
168
+ // runs-as-root indicator: file fires if NO non-root USER directive
169
+ // was seen anywhere. (sawAnyUser=false also counts — image defaults
170
+ // to root.)
171
+ if (!sawNonRootUser) {
172
+ hits["dockerfile-runs-as-root"].push({ file: rel, line: 0, snippet: sawAnyUser ? "USER directive sets root/0" : "no USER directive (defaults to root)" });
173
+ }
174
+
175
+ return hits;
176
+ }
177
+
178
+ /**
179
+ * docker-compose pattern matchers. Heuristic: match `key: true` /
180
+ * `key: <value>` lines, capturing the service block path where
181
+ * possible.
182
+ */
183
+ function scanCompose(content, rel) {
184
+ const lines = content.split(/\r?\n/);
185
+ const hits = {
186
+ "compose-privileged": [],
187
+ "compose-cap-add-sys-admin": [],
188
+ "compose-host-network": [],
189
+ "compose-docker-sock-mount": [],
190
+ };
191
+
192
+ for (let i = 0; i < lines.length; i++) {
193
+ const line = lines[i];
194
+ if (/^\s*#/.test(line)) continue;
195
+ if (/^\s*privileged:\s*true\b/i.test(line)) hits["compose-privileged"].push({ file: rel, line: i + 1, snippet: line.trim() });
196
+ // compose-host-network: per playbook, fires on any of
197
+ // network_mode: host, pid: host, ipc: host.
198
+ if (/^\s*network_mode:\s*['"]?host\b/i.test(line) ||
199
+ /^\s*pid:\s*['"]?host['"]?\s*$/i.test(line) ||
200
+ /^\s*ipc:\s*['"]?host['"]?\s*$/i.test(line)) {
201
+ hits["compose-host-network"].push({ file: rel, line: i + 1, snippet: line.trim() });
202
+ }
203
+ // compose-cap-add-sys-admin: per playbook, fires on
204
+ // SYS_ADMIN, SYS_PTRACE, or SYS_MODULE under cap_add. Either
205
+ // inline (cap_add: [SYS_ADMIN, SYS_PTRACE]) or multi-line
206
+ // (cap_add:\n - SYS_PTRACE).
207
+ const RISKY_CAPS_RE = /\b(?:CAP_)?(?:SYS_ADMIN|SYS_PTRACE|SYS_MODULE)\b/i;
208
+ if (/cap_add:.*\[/i.test(line) && RISKY_CAPS_RE.test(line)) {
209
+ hits["compose-cap-add-sys-admin"].push({ file: rel, line: i + 1, snippet: line.trim() });
210
+ } else if (/^\s*-\s*['"]?(?:CAP_)?(?:SYS_ADMIN|SYS_PTRACE|SYS_MODULE)\b/i.test(line) &&
211
+ lines.slice(Math.max(0, i - 5), i).some(l => /cap_add:/i.test(l))) {
212
+ hits["compose-cap-add-sys-admin"].push({ file: rel, line: i + 1, snippet: line.trim() });
213
+ }
214
+ // docker-sock mount: /var/run/docker.sock anywhere in the value
215
+ if (/\/var\/run\/docker\.sock/.test(line)) {
216
+ hits["compose-docker-sock-mount"].push({ file: rel, line: i + 1, snippet: line.trim() });
217
+ }
218
+ }
219
+ return hits;
220
+ }
221
+
222
+ /**
223
+ * k8s manifest pattern matchers. Heuristic line-scanning + kind-
224
+ * level guards (e.g. ClusterRoleBinding for cluster-admin check).
225
+ */
226
+ function scanK8s(content, rel) {
227
+ const lines = content.split(/\r?\n/);
228
+ const kind = extractKind(content);
229
+ const hits = {
230
+ "k8s-privileged": [],
231
+ "k8s-host-namespaces": [],
232
+ "k8s-run-as-root": [],
233
+ "k8s-hostpath-sensitive": [],
234
+ "k8s-image-latest": [],
235
+ "k8s-cluster-admin-binding": [],
236
+ };
237
+
238
+ for (let i = 0; i < lines.length; i++) {
239
+ const line = lines[i];
240
+ if (/^\s*#/.test(line)) continue;
241
+ if (/^\s*privileged:\s*true\b/i.test(line)) hits["k8s-privileged"].push({ file: rel, line: i + 1, snippet: line.trim() });
242
+ if (/^\s*(hostNetwork|hostPID|hostIPC):\s*true\b/.test(line)) hits["k8s-host-namespaces"].push({ file: rel, line: i + 1, snippet: line.trim() });
243
+ // k8s-run-as-root: per playbook, fires on runAsUser: 0 OR
244
+ // runAsNonRoot: false. (The "runAsUser unset AND image runs as
245
+ // root" clause requires image inspection the collector can't do
246
+ // without a container runtime; leave that to the runner when
247
+ // operator-supplied image-inspection evidence is available.)
248
+ if (/^\s*runAsUser:\s*0\b/.test(line) ||
249
+ /^\s*runAsNonRoot:\s*false\b/.test(line)) {
250
+ hits["k8s-run-as-root"].push({ file: rel, line: i + 1, snippet: line.trim() });
251
+ }
252
+ // hostPath sensitive: /, /etc, /var/lib/docker, /proc, /sys
253
+ const hpMatch = line.match(/^\s*path:\s*['"]?(\/(?:etc|proc|sys|var\/lib\/docker|var\/run|root|home)?\/?)['"]?\s*$/);
254
+ if (hpMatch && lines.slice(Math.max(0, i - 3), i).some(l => /hostPath:/i.test(l))) {
255
+ hits["k8s-hostpath-sensitive"].push({ file: rel, line: i + 1, snippet: line.trim() });
256
+ }
257
+ // image: ...:latest OR image: ... (no tag, defaults to latest)
258
+ // Allow optional leading `-` from a YAML list item: `- image: ...`.
259
+ const imageMatch = line.match(/^\s*-?\s*image:\s*['"]?([^'"@\s]+)(?:@[^'"]+)?['"]?\s*$/);
260
+ if (imageMatch) {
261
+ const ref = imageMatch[1];
262
+ const tagMatch = ref.match(/:([^/]+)$/);
263
+ const tag = tagMatch ? tagMatch[1] : null;
264
+ // Skip if @sha256:... pinned (the full regex captured ref without @-suffix).
265
+ const hasDigest = /@sha256:[0-9a-f]{64}/.test(line);
266
+ if (!hasDigest && (tag === "latest" || tag === null)) {
267
+ hits["k8s-image-latest"].push({ file: rel, line: i + 1, snippet: line.trim() });
268
+ }
269
+ }
270
+ }
271
+
272
+ // ClusterRoleBinding referencing cluster-admin
273
+ if (kind === "ClusterRoleBinding" || kind === "RoleBinding") {
274
+ if (/name:\s*['"]?cluster-admin['"]?/m.test(content)) {
275
+ hits["k8s-cluster-admin-binding"].push({ file: rel, line: 0, snippet: `${kind} binds cluster-admin` });
276
+ }
277
+ }
278
+
279
+ return hits;
280
+ }
281
+
282
+ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
283
+ const errors = [];
284
+ const startTime = Date.now();
285
+ const root = path.resolve(cwd);
286
+
287
+ let files;
288
+ try { files = walkTree(root); }
289
+ catch (e) { errors.push({ kind: "walk_failed", reason: e.message }); files = []; }
290
+
291
+ const dockerfiles = [];
292
+ const composeFiles = [];
293
+ const k8sManifests = [];
294
+ for (const f of files) {
295
+ const c = classify(f);
296
+ if (c.isDockerfile) dockerfiles.push(f);
297
+ if (c.isCompose) composeFiles.push(f);
298
+ if (c.isYaml && !c.isCompose) {
299
+ const content = readSafe(f.full);
300
+ if (content && looksLikeK8sManifest(content)) k8sManifests.push({ ...f, content });
301
+ }
302
+ }
303
+
304
+ // Aggregate hits per indicator.
305
+ const allHits = {};
306
+ for (const id of [
307
+ "dockerfile-from-latest", "dockerfile-no-digest-pin", "dockerfile-runs-as-root", "dockerfile-curl-pipe-bash",
308
+ "compose-privileged", "compose-cap-add-sys-admin", "compose-host-network", "compose-docker-sock-mount",
309
+ "k8s-privileged", "k8s-host-namespaces", "k8s-run-as-root", "k8s-hostpath-sensitive",
310
+ "k8s-image-latest", "k8s-cluster-admin-binding",
311
+ ]) allHits[id] = [];
312
+
313
+ for (const f of dockerfiles) {
314
+ const content = readSafe(f.full);
315
+ if (!content) {
316
+ errors.push({ artifact_id: "dockerfile-content", kind: "read_failed", reason: `${f.rel}: read returned null` });
317
+ continue;
318
+ }
319
+ const fileHits = scanDockerfile(content, f.rel);
320
+ for (const [id, list] of Object.entries(fileHits)) allHits[id].push(...list);
321
+ }
322
+ for (const f of composeFiles) {
323
+ const content = readSafe(f.full);
324
+ if (!content) {
325
+ errors.push({ artifact_id: "compose-files", kind: "read_failed", reason: `${f.rel}: read returned null` });
326
+ continue;
327
+ }
328
+ const fileHits = scanCompose(content, f.rel);
329
+ for (const [id, list] of Object.entries(fileHits)) allHits[id].push(...list);
330
+ }
331
+ for (const f of k8sManifests) {
332
+ const fileHits = scanK8s(f.content, f.rel);
333
+ for (const [id, list] of Object.entries(fileHits)) allHits[id].push(...list);
334
+ }
335
+
336
+ // signal_overrides — flip every indicator the collector can decide
337
+ // deterministically. (k8s-no-seccomp-profile / psa-policy-* /
338
+ // network-policies-absent require cluster-API access the collector
339
+ // doesn't have; leave them unflipped so the runner returns
340
+ // inconclusive rather than a forced miss.)
341
+ const signal_overrides = {};
342
+ for (const [id, list] of Object.entries(allHits)) {
343
+ signal_overrides[id] = list.length > 0 ? "hit" : "miss";
344
+ }
345
+
346
+ const summarize = (list, limit = 5) => {
347
+ if (list.length === 0) return "0 hits";
348
+ const sample = list.slice(0, limit).map(h => `${h.file}:${h.line}`).join(", ");
349
+ return `${list.length} hit(s): ${sample}${list.length > limit ? ", …" : ""}`;
350
+ };
351
+
352
+ const artifacts = {
353
+ "dockerfile-inventory": {
354
+ value: dockerfiles.length ? dockerfiles.map(f => f.rel).join(", ") : "no Dockerfiles found",
355
+ captured: true,
356
+ },
357
+ "dockerfile-content": {
358
+ value: dockerfiles.length
359
+ ? `${dockerfiles.length} file(s) scanned; per-indicator hits: ` +
360
+ [
361
+ `from-latest=${summarize(allHits["dockerfile-from-latest"])}`,
362
+ `no-digest-pin=${summarize(allHits["dockerfile-no-digest-pin"])}`,
363
+ `runs-as-root=${summarize(allHits["dockerfile-runs-as-root"])}`,
364
+ `curl-pipe-bash=${summarize(allHits["dockerfile-curl-pipe-bash"])}`,
365
+ ].join("; ")
366
+ : "no Dockerfile content to scan",
367
+ captured: true,
368
+ },
369
+ "compose-files": {
370
+ value: composeFiles.length ? composeFiles.map(f => f.rel).join(", ") : "no docker-compose files found",
371
+ captured: true,
372
+ },
373
+ "k8s-manifests": {
374
+ value: k8sManifests.length
375
+ ? k8sManifests.map(f => `${f.rel} (kind=${extractKind(f.content) || "?"})`).join(", ")
376
+ : "no k8s manifests found",
377
+ captured: true,
378
+ },
379
+ };
380
+
381
+ return {
382
+ precondition_checks: {
383
+ "container-tooling-available": true,
384
+ },
385
+ artifacts,
386
+ signal_overrides,
387
+ collector_meta: {
388
+ collector_id: COLLECTOR_ID,
389
+ collector_version: "2026-05-20",
390
+ platform: process.platform,
391
+ captured_at: new Date().toISOString(),
392
+ cwd: root,
393
+ duration_ms: Date.now() - startTime,
394
+ files_walked: files.length,
395
+ dockerfiles_found: dockerfiles.length,
396
+ compose_files_found: composeFiles.length,
397
+ k8s_manifests_found: k8sManifests.length,
398
+ },
399
+ collector_errors: errors,
400
+ };
401
+ }
402
+
403
+ module.exports = { playbook_id: COLLECTOR_ID, collect };
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/collectors/kernel.js
5
+ *
6
+ * Companion collector for the `kernel` playbook. Establishes
7
+ * preconditions (linux-platform / uname-available) and captures the
8
+ * kernel release string for the kver-in-affected-range indicator.
9
+ *
10
+ * Scope: Linux only. On macOS / Windows the playbook's linux-platform
11
+ * precondition halts at preflight; the collector reports that
12
+ * truthfully so the operator sees the visibility gap without the
13
+ * runner having to re-derive it.
14
+ *
15
+ * Interface: see lib/collectors/README.md
16
+ */
17
+
18
+ const { execFileSync } = require("node:child_process");
19
+ const path = require("node:path");
20
+
21
+ const COLLECTOR_ID = "kernel";
22
+
23
+ function runUname(arg) {
24
+ try {
25
+ const out = execFileSync("uname", [arg], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000 });
26
+ return { ok: true, value: out.trim() };
27
+ } catch (e) {
28
+ return { ok: false, reason: (e && e.message) || String(e) };
29
+ }
30
+ }
31
+
32
+ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
33
+ const errors = [];
34
+
35
+ // Precondition 1: linux-platform. Use process.platform first
36
+ // (Node-derived, always available); cross-check against uname -s
37
+ // when available.
38
+ const linuxPlatform = process.platform === "linux";
39
+
40
+ // Precondition 2: uname-available. Pure capability check.
41
+ const unameR = runUname("-r");
42
+ const unameAvailable = unameR.ok;
43
+ if (!unameAvailable && linuxPlatform) {
44
+ errors.push({
45
+ kind: "command_unavailable",
46
+ reason: `\`uname -r\` failed on linux: ${unameR.reason}`,
47
+ });
48
+ }
49
+
50
+ // Artifact: kernel-release. The exact string returned by `uname -r`,
51
+ // e.g. "5.15.0-69-generic". When uname is unavailable, the artifact
52
+ // is captured=false with the reason; the runner treats the
53
+ // dependent indicators as inconclusive.
54
+ const artifacts = {};
55
+ if (unameR.ok) {
56
+ artifacts["kernel-release"] = { value: unameR.value, captured: true };
57
+ } else {
58
+ artifacts["kernel-release"] = {
59
+ value: null,
60
+ captured: false,
61
+ reason: linuxPlatform
62
+ ? `uname -r failed: ${unameR.reason}`
63
+ : `non-linux platform (${process.platform}); uname not invoked`,
64
+ };
65
+ }
66
+
67
+ // Optional artifact: cmdline. Not always required but useful for
68
+ // KASLR / unpriv-userns / unpriv-bpf indicator evaluation. Read
69
+ // from /proc directly so we don't fork another process.
70
+ if (linuxPlatform) {
71
+ try {
72
+ const fs = require("node:fs");
73
+ const cmdline = fs.readFileSync("/proc/cmdline", "utf8").trim();
74
+ artifacts["kernel-cmdline"] = { value: cmdline, captured: true };
75
+ } catch (e) {
76
+ errors.push({
77
+ artifact_id: "kernel-cmdline",
78
+ kind: "read_failed",
79
+ reason: `/proc/cmdline read failed: ${e.message}`,
80
+ });
81
+ }
82
+ // sysctl snapshot for kernel.unprivileged_userns_clone +
83
+ // kernel.unprivileged_bpf_disabled when readable.
84
+ try {
85
+ const fs = require("node:fs");
86
+ const sysctls = {};
87
+ const paths = [
88
+ "/proc/sys/kernel/unprivileged_userns_clone",
89
+ "/proc/sys/kernel/unprivileged_bpf_disabled",
90
+ "/proc/sys/kernel/randomize_va_space",
91
+ ];
92
+ for (const p of paths) {
93
+ try {
94
+ sysctls[path.basename(p)] = fs.readFileSync(p, "utf8").trim();
95
+ } catch {
96
+ // Best-effort; a missing file usually means the sysctl
97
+ // doesn't exist on this kernel.
98
+ }
99
+ }
100
+ if (Object.keys(sysctls).length) {
101
+ artifacts["sysctl-snapshot"] = { value: JSON.stringify(sysctls), captured: true };
102
+ }
103
+ } catch (e) {
104
+ errors.push({
105
+ artifact_id: "sysctl-snapshot",
106
+ kind: "read_failed",
107
+ reason: e.message,
108
+ });
109
+ }
110
+ }
111
+
112
+ // Signal overrides: we can't decide kver-in-affected-range without
113
+ // the CVE-affected-version catalog (the runner does that
114
+ // correlation). But we CAN flip the deterministic indicators that
115
+ // read directly off the sysctl snapshot.
116
+ const signal_overrides = {};
117
+ const sysctl = artifacts["sysctl-snapshot"];
118
+ if (sysctl && sysctl.captured) {
119
+ let parsed = null;
120
+ try { parsed = JSON.parse(sysctl.value); } catch {}
121
+ if (parsed) {
122
+ // kaslr-disabled: randomize_va_space < 2 (0 = off, 1 = partial, 2 = full).
123
+ if (parsed.randomize_va_space != null) {
124
+ const v = parseInt(parsed.randomize_va_space, 10);
125
+ signal_overrides["kaslr-disabled"] = (v < 2) ? "hit" : "miss";
126
+ }
127
+ // unpriv-userns-enabled: clone == 1 means enabled (risky).
128
+ if (parsed.unprivileged_userns_clone != null) {
129
+ const v = parseInt(parsed.unprivileged_userns_clone, 10);
130
+ signal_overrides["unpriv-userns-enabled"] = (v === 1) ? "hit" : "miss";
131
+ }
132
+ // unpriv-bpf-allowed: bpf_disabled == 0 means unprivileged BPF
133
+ // is allowed (risky).
134
+ if (parsed.unprivileged_bpf_disabled != null) {
135
+ const v = parseInt(parsed.unprivileged_bpf_disabled, 10);
136
+ signal_overrides["unpriv-bpf-allowed"] = (v === 0) ? "hit" : "miss";
137
+ }
138
+ }
139
+ }
140
+
141
+ return {
142
+ precondition_checks: {
143
+ "linux-platform": linuxPlatform,
144
+ "uname-available": unameAvailable,
145
+ },
146
+ artifacts,
147
+ signal_overrides,
148
+ collector_meta: {
149
+ collector_id: COLLECTOR_ID,
150
+ collector_version: "2026-05-20",
151
+ platform: process.platform,
152
+ captured_at: new Date().toISOString(),
153
+ cwd,
154
+ },
155
+ collector_errors: errors,
156
+ };
157
+ }
158
+
159
+ module.exports = { playbook_id: COLLECTOR_ID, collect };