@blamejs/exceptd-skills 0.13.33 → 0.13.34

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 CHANGED
@@ -362,6 +362,44 @@ Maintainers convert approved requests into skill files. The contributor is credi
362
362
 
363
363
  ---
364
364
 
365
+ ## Evidence collection roadmap
366
+
367
+ Today every playbook declares **what** evidence its detect phase needs via `phases.look.artifacts[].source` — a prose-shaped string that an AI assistant (or operator) reads, interprets, and translates into a concrete filesystem walk / shell command / API call. exceptd ships the knowledge layer; evidence collection lives in the consumer.
368
+
369
+ This split costs every consumer the same translation work on every invocation. CI is the most visible place that hurts (no human-in-loop), but every AI-driven workflow pays the cost on every run. The longer-term direction is **companion collector scripts** under `lib/collectors/<playbook-id>.js` that turn the prose into executable Node — pure stdlib walks + regex + child_process where needed — emitting the evidence JSON in the same shape `exceptd run --evidence -` accepts. The wrapper verb is `exceptd collect <playbook>`; the operator pipes:
370
+
371
+ ```bash
372
+ exceptd collect secrets | exceptd run secrets --evidence -
373
+ ```
374
+
375
+ The collector library is small and grows as playbooks are touched. Three reference collectors ship today (`lib/collectors/secrets.js`, `lib/collectors/kernel.js`, `lib/collectors/sbom.js`); the rest are written when each playbook needs them. Until a playbook has a collector, the AI/operator owns evidence collection as before.
376
+
377
+ ### Precision target for new `look.artifacts[].source` strings
378
+
379
+ When adding or editing a playbook artifact, treat the `source` string as **the spec a future collector will implement against**. Write it precise enough that an attentive script author could turn it into Node code without guessing. Concretely:
380
+
381
+ - **File-based artifacts** name explicit globs (`*.tf, *.tfvars, *.tfvars.json`) plus exclusion list when relevant (`exclude **/node_modules/**, **/.git/objects/**, **/dist/**, **/build/**`). Avoid open-ended phrasing like "common config files".
382
+ - **Filesystem-walk artifacts** declare the walk root (cwd, `~`, an explicit path), depth limit if any, and the predicate shape (filename match, content match, permission check, symlink resolution policy).
383
+ - **Command-driven artifacts** name the exact command per platform when the call varies (`AWS: aws iam ... ; GCP: gcloud ... ; Azure: az ...`). The current cloud-incident playbooks follow this pattern; copy that shape.
384
+ - **Regex / content matching** — the canonical pattern lives in the detect phase's `indicators[].value` field, not in the artifact `source`. Reference it explicitly (`grep against the catalogued secret patterns — see detect.indicators for the regex set`) so the collector knows where to read the patterns from.
385
+ - **Aggregations of other artifacts** name the input artifact IDs explicitly (`For every file matched by env-files OR auth-config-files OR ssh-private-keys: stat for ...`). The collector composes per-artifact outputs.
386
+
387
+ When a `source` string would otherwise drift into prose ("the operator should consider…", "typically located in…"), the artifact is probably under-specified. Tighten it before merging.
388
+
389
+ ### When a collector is required vs optional
390
+
391
+ For now, collectors are optional — every playbook still works through the AI-evidence path. A new playbook **must** carry a collector when:
392
+
393
+ - It's a code-scope or system-scope playbook with a deterministic detect-phase regex / filesystem-walk shape (the kind a CI pipeline would gate on).
394
+ - The cost of writing the collector is bounded (≤300 lines of Node, stdlib + child_process only).
395
+
396
+ A new playbook **should not** ship a collector when:
397
+
398
+ - The detect phase requires operator judgement (`framework`, `ransomware`, jurisdiction-specific incident playbooks where the inputs are interview-shaped).
399
+ - Evidence collection requires credentials the tool shouldn't ask for (cloud API keys, identity-provider admin tokens). Those stay AI-driven for now.
400
+
401
+ When in doubt, ship the playbook without a collector and open the gap as a follow-up.
402
+
365
403
  ## Pre-Ship Checklist
366
404
 
367
405
  - [ ] All new CVEs have complete `data/cve-catalog.json` entries
@@ -379,6 +417,8 @@ Maintainers convert approved requests into skill files. The contributor is credi
379
417
  - [ ] CHANGELOG.md updated with what changed, what CVEs were added, what gaps were closed or opened
380
418
  - [ ] No partial skills — if it can't be completed now, branch it, don't merge it
381
419
  - [ ] Global coverage: EU + UK + AU + ISO 27001 present in all framework gap analyses
420
+ - [ ] `look.artifacts[].source` strings meet the precision target (see § Evidence collection roadmap). New artifacts ship globs / commands / artifact-id references — not prose like "typically located in" or "the operator should consider"
421
+ - [ ] When the playbook is code-scope or system-scope with a deterministic detect shape: a companion collector exists at `lib/collectors/<playbook-id>.js` and runs clean via `exceptd collect <id> | exceptd run <id> --evidence -`
382
422
 
383
423
  ---
384
424
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.34 — 2026-05-20
4
+
5
+ Evidence-collection layer (Option A from the cold-start workflow audit). New verb `exceptd collect <playbook>` runs a companion script per playbook that walks the cwd, applies the catalogued regex set, stats permissions, and emits the submission JSON in the same shape `exceptd run --evidence -` accepts. The operator pipes:
6
+
7
+ ```bash
8
+ exceptd collect secrets | exceptd run secrets --evidence -
9
+ ```
10
+
11
+ The collector library is small and grows as playbooks are touched.
12
+
13
+ ### Features
14
+
15
+ - **New verb `exceptd collect <playbook>`.** Loads `lib/collectors/<playbook>.js`, runs `collect({ cwd, env, args })`, emits the submission JSON. `--cwd <path>` collects against a different repo / host. `--pretty` for indented JSON. Default output is a one-screen human digest (preconditions / artifacts / indicators-that-fired / collector warnings / next-step pipe pointer); `--json` for machine consumption. Exit codes: `0` ok, `1` collector-not-found (the AI-evidence path remains — `exceptd lint <pb> -` documents the submission shape), `2` collector threw unhandled.
16
+ - **Three reference collectors ship.** `lib/collectors/secrets.js` walks the cwd tree (depth 6, exclude `node_modules`/`.git`/`dist`/`build`/etc.), runs the catalogued secret regex set against text files (with literal-redaction so the attestation doesn't become a leak vector), stats permission posture on secret-carrier files, and flips `signal_overrides` per indicator that fired. `lib/collectors/kernel.js` derives the `linux-platform` + `uname-available` preconditions, captures `uname -r` + `/proc/cmdline` + selected `/proc/sys/kernel/*` snapshots, and flips `kaslr-disabled` / `unpriv-userns-enabled` / `unpriv-bpf-allowed` from the sysctl values. `lib/collectors/sbom.js` recognises 10 lockfile types (npm / yarn / pnpm / pip / pipenv / poetry / cargo / go / rubygems / composer) plus CycloneDX / SPDX SBOM documents and flips `sbom-document-absent` / `lockfile-absent`.
17
+ - **Collector contract codified at `lib/collectors/README.md`.** Pure stdlib + child_process only, synchronous, errors don't throw (surfaced via `collector_errors[]`), literal secret bytes redacted in artifact values, indicators win over artifacts when the collector can decide deterministically.
18
+
19
+ ### Internal
20
+
21
+ - `AGENTS.md` § Evidence collection roadmap documents the WHY + the precision target for `look.artifacts[].source` strings (file globs + per-platform commands + artifact-id references — not prose) and the when-required policy for adding a collector with a new playbook.
22
+
3
23
  ## 0.13.33 — 2026-05-20
4
24
 
5
25
  ### Features
package/bin/exceptd.js CHANGED
@@ -217,7 +217,7 @@ function suggestVerb(cmd, known) {
217
217
  // remain operationally useful and have substantial test coverage.
218
218
  const PLAYBOOK_VERBS = new Set([
219
219
  "brief", "run", "ai-run", "attest", "discover", "doctor", "ci", "ask",
220
- "verify-attestation", "run-all", "lint",
220
+ "verify-attestation", "run-all", "lint", "collect",
221
221
  "reattest", "list-attestations",
222
222
  ]);
223
223
 
@@ -1151,6 +1151,7 @@ function dispatchPlaybook(cmd, argv) {
1151
1151
  "evidence", "evidence-dir", "session-id", "operator", "csaf-status",
1152
1152
  "publisher-namespace", "mode", "scope", "playbook", "phase", "tlp",
1153
1153
  "against", "since", "bundle-epoch", "attestation-root", "format",
1154
+ "cwd", // exceptd collect <pb> --cwd <path>
1154
1155
  ]);
1155
1156
  const verbAllowlist = flagsFor(cmd);
1156
1157
  const allowlistSet = new Set(verbAllowlist);
@@ -1576,6 +1577,7 @@ function dispatchPlaybook(cmd, argv) {
1576
1577
  case "ai-run": return cmdAiRun(runner, args, runOpts, pretty);
1577
1578
  case "ask": return cmdAsk(runner, args, runOpts, pretty);
1578
1579
  case "ci": return cmdCi(runner, args, runOpts, pretty);
1580
+ case "collect": return cmdCollect(runner, args, runOpts, pretty);
1579
1581
  }
1580
1582
  } catch (e) {
1581
1583
  // v0.11.14 (#131): when the operator typed a skill name (kernel-lpe-triage)
@@ -2181,6 +2183,119 @@ Flags (selected — see \`exceptd run --help\` for the full list):
2181
2183
  * its evidence JSON before going through phases 4-7. Returns a categorized
2182
2184
  * list: ok / missing_required / unknown_keys / type_mismatch / suggestions.
2183
2185
  */
2186
+ function cmdCollect(runner, args, runOpts, pretty) {
2187
+ const playbookId = args._[0];
2188
+ if (!playbookId) {
2189
+ return emitError("collect: usage: exceptd collect <playbook>. See `lib/collectors/` for the list of playbooks with companion collectors.", null, pretty);
2190
+ }
2191
+ if (refuseInvalidPlaybookId("collect", playbookId, pretty)) return;
2192
+
2193
+ // Resolve the collector module under lib/collectors/<id>.js.
2194
+ const collectorPath = path.join(PKG_ROOT, "lib", "collectors", `${playbookId}.js`);
2195
+ if (!fs.existsSync(collectorPath)) {
2196
+ const collectorsDir = path.join(PKG_ROOT, "lib", "collectors");
2197
+ let available = [];
2198
+ try {
2199
+ available = fs.readdirSync(collectorsDir)
2200
+ .filter(f => f.endsWith(".js"))
2201
+ .map(f => f.replace(/\.js$/, ""));
2202
+ } catch {}
2203
+ return emitError(
2204
+ `collect: no companion collector for "${playbookId}". The AI-evidence path remains: see \`exceptd lint ${playbookId} -\` for the submission shape and supply your own evidence to \`exceptd run ${playbookId} --evidence -\`.`,
2205
+ {
2206
+ verb: "collect",
2207
+ playbook_id: playbookId,
2208
+ collectors_available: available,
2209
+ type: "collector_not_found",
2210
+ exit_code: 1,
2211
+ },
2212
+ pretty
2213
+ );
2214
+ }
2215
+
2216
+ let mod;
2217
+ try { mod = require(collectorPath); }
2218
+ catch (e) {
2219
+ return emitError(`collect: failed to load collector ${path.relative(PKG_ROOT, collectorPath)}: ${e.message}`, { verb: "collect", playbook_id: playbookId, exit_code: 2 }, pretty);
2220
+ }
2221
+ if (typeof mod.collect !== "function") {
2222
+ return emitError(`collect: collector at ${path.relative(PKG_ROOT, collectorPath)} does not export a collect() function`, { verb: "collect", playbook_id: playbookId, exit_code: 2 }, pretty);
2223
+ }
2224
+
2225
+ // --cwd <path> overrides process.cwd(). Validated as an existing
2226
+ // directory; non-existent / non-directory cwd is operator error.
2227
+ let cwd = process.cwd();
2228
+ if (args.cwd) {
2229
+ const resolved = path.resolve(String(args.cwd));
2230
+ let stat;
2231
+ try { stat = fs.statSync(resolved); }
2232
+ catch (e) {
2233
+ return emitError(`collect: --cwd "${args.cwd}" does not exist (${e.message})`, { verb: "collect", playbook_id: playbookId, provided_cwd: args.cwd }, pretty);
2234
+ }
2235
+ if (!stat.isDirectory()) {
2236
+ return emitError(`collect: --cwd "${args.cwd}" is not a directory`, { verb: "collect", playbook_id: playbookId, provided_cwd: args.cwd }, pretty);
2237
+ }
2238
+ cwd = resolved;
2239
+ }
2240
+
2241
+ let submission;
2242
+ try {
2243
+ submission = mod.collect({ cwd, env: process.env, args });
2244
+ } catch (e) {
2245
+ return emitError(
2246
+ `collect: collector for "${playbookId}" threw an unhandled exception: ${e.message}. File a bug — collectors must catch their own errors and surface them via collector_errors[].`,
2247
+ { verb: "collect", playbook_id: playbookId, stack: e.stack || null, exit_code: 2 },
2248
+ pretty
2249
+ );
2250
+ }
2251
+
2252
+ // Emit the submission JSON to stdout. The operator pipes this into
2253
+ // `exceptd run <playbook> --evidence -` to drive a real verdict.
2254
+ // Human-rendered version is concise so an interactive operator can
2255
+ // see what the collector found without parsing the JSON.
2256
+ emit({ verb: "collect", playbook_id: playbookId, ...submission }, pretty, (obj) => {
2257
+ const lines = [];
2258
+ const meta = obj.collector_meta || {};
2259
+ lines.push(`collect: ${obj.playbook_id} (${meta.collector_version || "?"} on ${meta.platform || "?"})`);
2260
+ if (meta.duration_ms != null) lines.push(` duration: ${meta.duration_ms}ms`);
2261
+ const pre = obj.precondition_checks || {};
2262
+ if (Object.keys(pre).length) {
2263
+ lines.push(`\nPreconditions:`);
2264
+ for (const [k, v] of Object.entries(pre)) {
2265
+ const icon = v ? "[ok]" : "[!!]";
2266
+ lines.push(` ${icon} ${k} = ${v}`);
2267
+ }
2268
+ }
2269
+ const artifacts = obj.artifacts || {};
2270
+ if (Object.keys(artifacts).length) {
2271
+ lines.push(`\nArtifacts:`);
2272
+ for (const [k, a] of Object.entries(artifacts)) {
2273
+ const icon = a.captured ? "[ok]" : "[skip]";
2274
+ const val = (a.value || "").length > 120 ? (a.value || "").slice(0, 117) + "..." : (a.value || "");
2275
+ lines.push(` ${icon} ${k}: ${val}`);
2276
+ if (!a.captured && a.reason) lines.push(` reason: ${a.reason}`);
2277
+ }
2278
+ }
2279
+ const signals = obj.signal_overrides || {};
2280
+ const hits = Object.entries(signals).filter(([, v]) => v === "hit");
2281
+ if (hits.length) {
2282
+ lines.push(`\nIndicators that fired (${hits.length}):`);
2283
+ for (const [k] of hits) lines.push(` [hit] ${k}`);
2284
+ }
2285
+ const errs = obj.collector_errors || [];
2286
+ if (errs.length) {
2287
+ lines.push(`\nCollector warnings (${errs.length}):`);
2288
+ for (const e of errs.slice(0, 5)) {
2289
+ lines.push(` [${e.kind || "warning"}] ${e.artifact_id ? e.artifact_id + ": " : ""}${e.reason || "(no detail)"}`);
2290
+ }
2291
+ if (errs.length > 5) lines.push(` … ${errs.length - 5} more`);
2292
+ }
2293
+ lines.push(`\n→ next: exceptd collect ${obj.playbook_id} --json | exceptd run ${obj.playbook_id} --evidence -`);
2294
+ lines.push(`Full structured result: --json (or --pretty for indented JSON).`);
2295
+ return lines.join("\n");
2296
+ });
2297
+ }
2298
+
2184
2299
  function cmdLint(runner, args, runOpts, pretty) {
2185
2300
  const playbookId = args._[0];
2186
2301
  const evidencePath = args._[1] || args.evidence;
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-20T21:39:49.234Z",
3
+ "generated_at": "2026-05-20T23:16:10.900Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 54,
6
6
  "source_hashes": {
7
- "manifest.json": "fcd4692f64fe7107a4b620dfb0b963e88d7b5d6f7b977c4e87745a38f523ac30",
7
+ "manifest.json": "d479c5acd441d98c8e4f75ab90366994affe4eea457f43b7180209f696af9ea2",
8
8
  "data/atlas-ttps.json": "d296c1d3e71807c9279b731f047e57796e85137f186586743a8cdad214b408f9",
9
9
  "data/attack-techniques.json": "49b6010b317edd219def135171ea8f3b1bbf1e00e9c5a08bf7237215ff54e2c3",
10
10
  "data/cve-catalog.json": "a09c83af3f9679a7ea73935726a1ff9de2cab94b4ab6321fc017fc147747d7c3",
@@ -0,0 +1,76 @@
1
+ # Evidence collectors
2
+
3
+ Companion scripts that turn a playbook's `phases.look.artifacts[]` declarations into an actual evidence submission, so an operator (or a CI workflow) can produce a real verdict without a human-in-loop AI translating prose into filesystem walks every time.
4
+
5
+ ## Interface contract
6
+
7
+ Each collector lives at `lib/collectors/<playbook-id>.js` and exports:
8
+
9
+ ```js
10
+ module.exports = {
11
+ // Playbook id this collector implements. Must match the file name.
12
+ playbook_id: "<playbook-id>",
13
+
14
+ // Pure synchronous function. Walks the filesystem, runs child_process
15
+ // commands as needed, returns the submission JSON in the same shape
16
+ // `exceptd run --evidence -` accepts.
17
+ collect({ cwd, env, args }) {
18
+ return {
19
+ precondition_checks: { /* "<precondition-id>": true|false */ },
20
+ artifacts: {
21
+ /* "<artifact-id>": { value: <captured text>, captured: true|false, reason?: "<why captured=false>" } */
22
+ },
23
+ signal_overrides: { /* "<indicator-id>": "hit"|"miss"|"inconclusive" */ },
24
+ collector_meta: {
25
+ // Self-describing metadata so the operator knows WHAT the
26
+ // collector did and which version produced this evidence.
27
+ collector_id: "<playbook-id>",
28
+ collector_version: "<semver-or-date>",
29
+ platform: process.platform,
30
+ captured_at: new Date().toISOString(),
31
+ cwd: cwd,
32
+ },
33
+ // Collector-level errors that did NOT prevent producing a
34
+ // submission (e.g. "couldn't read /proc/version on Windows").
35
+ // Each entry is { artifact_id?, kind, reason }.
36
+ collector_errors: [],
37
+ };
38
+ },
39
+ };
40
+ ```
41
+
42
+ ### Rules
43
+
44
+ - **Stdlib + child_process only.** No npm dependencies beyond what's already vendored. The point is to ship inside the npm tarball and run anywhere Node runs.
45
+ - **No network calls.** Evidence collection is a local snapshot. Refreshing upstream data lives in `exceptd refresh`.
46
+ - **Synchronous.** Matches the rest of the bin/lib code shape. Async machinery would force colored functions through the runner.
47
+ - **Errors don't throw.** Catch every recoverable error and add an entry to `collector_errors[]` with a human-readable `reason`. The CLI wrapper turns `collector_errors[]` into runtime warnings on the run output.
48
+ - **Cwd is the only entry point.** Default `cwd` is `process.cwd()`. The operator can override via `exceptd collect <pb> --cwd <path>`.
49
+ - **Walk caps.** Filesystem walks default to depth 6 + the standard exclusion set (`node_modules/`, `.git/objects/`, `dist/`, `build/`, `.venv/`, `__pycache__/`). Override the depth + exclusions via the playbook's `look.artifacts[].source` declaration when it says so.
50
+ - **Don't leak secrets.** When an artifact captures file *contents* (e.g. the secret-regex-scan output), redact the matched literal in `value` — keep file path + offset + classifier, drop the actual key material. The point of the audit is finding the leak; persisting the leak in the attestation makes the attestation itself a leak vector.
51
+ - **Indicators win over artifacts.** When the collector can determine an indicator verdict deterministically (e.g. a regex match means `aws-access-key-id: hit`), set the `signal_overrides[<indicator>]` rather than relying on the runner to re-evaluate the artifact text. Faster + more honest.
52
+
53
+ ## CLI
54
+
55
+ ```bash
56
+ exceptd collect <playbook> # walk cwd, emit submission JSON to stdout
57
+ exceptd collect <playbook> --cwd <path> # collect against a different repo / host
58
+ exceptd collect <playbook> --pretty # indented JSON for the dev loop
59
+ exceptd collect <playbook> | exceptd run <playbook> --evidence - # full loop
60
+ ```
61
+
62
+ Exit codes:
63
+
64
+ - `0` — submission emitted successfully (operator should check `collector_errors[]` for partial-evidence warnings)
65
+ - `1` — no collector exists for the playbook id (the AI-evidence path remains)
66
+ - `2` — collector threw an unhandled exception (file a bug)
67
+
68
+ ## When to write a collector
69
+
70
+ See `AGENTS.md § Evidence collection roadmap` for the policy. Summary: code-scope and system-scope playbooks with deterministic detect shapes are good candidates; judgement-shaped playbooks (`framework`, `ransomware`, incident playbooks) stay AI-driven.
71
+
72
+ ## Reference collectors
73
+
74
+ - [`secrets.js`](secrets.js) — filesystem walk + regex against the catalogued secret patterns + permission-posture stat
75
+ - [`kernel.js`](kernel.js) — `uname -s` / `uname -r` for linux-platform + kernel-release detection
76
+ - [`sbom.js`](sbom.js) — lockfile presence + ecosystem fingerprint
@@ -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 };