@blamejs/exceptd-skills 0.13.32 → 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 +40 -0
- package/CHANGELOG.md +26 -0
- package/bin/exceptd.js +195 -2
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/README.md +76 -0
- package/lib/collectors/kernel.js +159 -0
- package/lib/collectors/sbom.js +278 -0
- package/lib/collectors/secrets.js +323 -0
- package/lib/flag-suggest.js +2 -1
- package/manifest.json +44 -44
- package/package.json +1 -1
- package/sbom.cdx.json +78 -18
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,31 @@
|
|
|
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
|
+
|
|
23
|
+
## 0.13.33 — 2026-05-20
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
- **`brief` (no arg) renders a scannable per-scope table.** `exceptd brief` with no playbook positional and no `--all` flag now produces a one-screen human digest: header with playbook count + session id, per-scope summary (`service=9 cross-cutting=4 code=4 system=6`), then a bucketed list per scope showing `<id> tcs=<score> <domain.name (truncated to 80 chars)>`, then a `Next:` block pointing at `brief <playbook>` for the full info doc, `discover` for cwd-aware recommendations, and `ci --scope <type>` for gating. Previously the verb dumped 36+ KB of JSON to the terminal — exploration was unscannable. `--json` / `--pretty` reach the structured envelope when automating.
|
|
28
|
+
|
|
3
29
|
## 0.13.32 — 2026-05-20
|
|
4
30
|
|
|
5
31
|
Two more JSON-only paths get human-renderer treatment.
|
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;
|
|
@@ -2504,7 +2619,85 @@ function cmdPlan(runner, args, runOpts, pretty) {
|
|
|
2504
2619
|
});
|
|
2505
2620
|
}
|
|
2506
2621
|
}
|
|
2507
|
-
emit(plan, pretty)
|
|
2622
|
+
emit(plan, pretty, (obj) => {
|
|
2623
|
+
// Human renderer for `brief` / `brief --all` / `plan`. Pre-fix this
|
|
2624
|
+
// verb dumped 36+ KB of JSON to the terminal — operators running
|
|
2625
|
+
// `exceptd brief` to explore had no scannable view.
|
|
2626
|
+
const lines = [];
|
|
2627
|
+
const summary = obj.scope_summary || {};
|
|
2628
|
+
const totalScope = Object.values(summary).reduce((a, b) => a + b, 0);
|
|
2629
|
+
const total = obj.playbooks?.length || 0;
|
|
2630
|
+
lines.push(`brief: ${total} playbook(s) session-id: ${obj.session_id}`);
|
|
2631
|
+
if (totalScope > 0) {
|
|
2632
|
+
const scopeLine = Object.entries(summary).map(([s, n]) => `${s}=${n}`).join(" ");
|
|
2633
|
+
lines.push(` ${scopeLine}`);
|
|
2634
|
+
}
|
|
2635
|
+
lines.push("");
|
|
2636
|
+
|
|
2637
|
+
// Group by scope when grouped output is available; else flat list.
|
|
2638
|
+
// grouped_by_scope is `{ scope: [<playbook-id>, ...] }` — look up
|
|
2639
|
+
// domain.name + threat_currency_score from the flat playbooks list.
|
|
2640
|
+
const byId = {};
|
|
2641
|
+
for (const pb of obj.playbooks || []) {
|
|
2642
|
+
if (pb && pb.id) byId[pb.id] = pb;
|
|
2643
|
+
}
|
|
2644
|
+
// Render directive sub-bullets when each playbook entry carries a
|
|
2645
|
+
// directives[] array (set by cmdPlan when --directives is on).
|
|
2646
|
+
// Without this, the documented contract of --directives — expand
|
|
2647
|
+
// directive metadata — is silently dropped in default human mode.
|
|
2648
|
+
const renderDirectives = (pb) => {
|
|
2649
|
+
const dirs = pb && Array.isArray(pb.directives) ? pb.directives : null;
|
|
2650
|
+
if (!dirs || !dirs.length) return;
|
|
2651
|
+
for (const d of dirs) {
|
|
2652
|
+
const title = d.title || d.id || "?";
|
|
2653
|
+
const truncTitle = title.length > 80 ? title.slice(0, 77) + "..." : title;
|
|
2654
|
+
lines.push(` → ${(d.id || "?").padEnd(48)} ${truncTitle}`);
|
|
2655
|
+
if (d.threat_context_preview) {
|
|
2656
|
+
const ctx = d.threat_context_preview;
|
|
2657
|
+
const truncCtx = ctx.length > 140 ? ctx.slice(0, 137) + "..." : ctx;
|
|
2658
|
+
lines.push(` ${truncCtx}`);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
|
|
2663
|
+
const grouped = obj.grouped_by_scope;
|
|
2664
|
+
if (grouped) {
|
|
2665
|
+
const scopeOrder = ["code", "system", "service", "cross-cutting"];
|
|
2666
|
+
const otherScopes = Object.keys(grouped).filter(s => !scopeOrder.includes(s));
|
|
2667
|
+
for (const scope of [...scopeOrder, ...otherScopes]) {
|
|
2668
|
+
const list = grouped[scope];
|
|
2669
|
+
if (!list || !list.length) continue;
|
|
2670
|
+
lines.push(`[${scope}] (${list.length})`);
|
|
2671
|
+
for (const id of list) {
|
|
2672
|
+
const pb = byId[id] || {};
|
|
2673
|
+
const tcs = pb.threat_currency_score != null ? ` tcs=${pb.threat_currency_score}` : "";
|
|
2674
|
+
const dom = pb.domain?.name || "";
|
|
2675
|
+
const truncDom = dom.length > 80 ? dom.slice(0, 77) + "..." : dom;
|
|
2676
|
+
lines.push(` ${(id || "?").padEnd(28)}${tcs.padEnd(8)} ${truncDom}`);
|
|
2677
|
+
renderDirectives(pb);
|
|
2678
|
+
}
|
|
2679
|
+
lines.push("");
|
|
2680
|
+
}
|
|
2681
|
+
} else {
|
|
2682
|
+
// Flat list (filtered or --flat). No scope buckets.
|
|
2683
|
+
for (const pb of obj.playbooks || []) {
|
|
2684
|
+
const tcs = pb.threat_currency_score != null ? ` tcs=${pb.threat_currency_score}` : "";
|
|
2685
|
+
const sc = pb.scope ? `[${pb.scope}]` : "[?]";
|
|
2686
|
+
const dom = pb.domain?.name || "";
|
|
2687
|
+
const truncDom = dom.length > 80 ? dom.slice(0, 77) + "..." : dom;
|
|
2688
|
+
lines.push(` ${sc.padEnd(16)} ${(pb.id || "?").padEnd(28)}${tcs.padEnd(8)} ${truncDom}`);
|
|
2689
|
+
renderDirectives(pb);
|
|
2690
|
+
}
|
|
2691
|
+
lines.push("");
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
lines.push(`Next:`);
|
|
2695
|
+
lines.push(` exceptd brief <playbook> # full info doc (jurisdictions + threat + indicators + artifacts)`);
|
|
2696
|
+
lines.push(` exceptd discover # cwd-aware playbook recommendations`);
|
|
2697
|
+
lines.push(` exceptd ci --scope <type> # gate a cwd against every playbook in <type>`);
|
|
2698
|
+
lines.push(`\nFull structured result: --json (or --pretty for indented JSON).`);
|
|
2699
|
+
return lines.join("\n");
|
|
2700
|
+
});
|
|
2508
2701
|
}
|
|
2509
2702
|
|
|
2510
2703
|
// v0.12.15: --scope must validate against the accepted
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-05-
|
|
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": "
|
|
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 };
|