@blamejs/exceptd-skills 0.13.34 → 0.13.36

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.36 — 2026-05-20
4
+
5
+ Fifth reference collector.
6
+
7
+ ### Features
8
+
9
+ - **`lib/collectors/library-author.js`** — audits a publisher-side repository for supply-chain posture markers. Flips 11 deterministic indicators: `publish-workflow-uses-static-token`, `publish-workflow-no-id-token-write`, `publish-workflow-action-refs-mutable` (any `uses: <action>@<ref>` where ref isn't a 40-char SHA), `release-workflow-non-frozen-install` (`npm install` vs `npm ci`, cargo without `--locked`), `publish-workflow-runs-on-self-hosted`, `package-json-provenance-missing` (no `publishConfig.provenance: true`), `lockfile-missing-integrity`, `sbom-absent-or-unsigned`, `no-security-md`, `no-security-txt`, `vendored-no-provenance`. Indicators that require GitHub API / sigstore lookup / GPG identity inspection (`tag-protection-absent`, `private-vuln-reporting-disabled`, `no-rekor-entry-for-latest-release`, etc.) are left unflipped — operator-supplied evidence remains the path for those.
10
+
11
+ ## 0.13.35 — 2026-05-20
12
+
13
+ Fourth reference collector + a sbom-collector indicator-pattern correction.
14
+
15
+ ### Features
16
+
17
+ - **`lib/collectors/containers.js`** — companion collector for the `containers` playbook. Walks cwd for Dockerfile / Containerfile / docker-compose.{yml,yaml} / k8s manifests (detected by `apiVersion:` + `kind:` line presence), applies pattern matchers for 11 catalogued deterministic indicators: `dockerfile-from-latest`, `dockerfile-no-digest-pin`, `dockerfile-runs-as-root`, `dockerfile-curl-pipe-bash`, `compose-privileged`, `compose-cap-add-sys-admin`, `compose-host-network`, `compose-docker-sock-mount`, `k8s-privileged`, `k8s-host-namespaces`, `k8s-run-as-root`, `k8s-hostpath-sensitive`, `k8s-image-latest`, `k8s-cluster-admin-binding`. Leaves indicators that require cluster-API access (`psa-policy-permissive-or-absent`, `network-policies-absent-from-workload-namespace`, `k8s-no-seccomp-profile`) unflipped — the runner can decide them when operator-supplied cluster snapshots are available.
18
+
3
19
  ## 0.13.34 — 2026-05-20
4
20
 
5
21
  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:
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-20T23:16:10.900Z",
3
+ "generated_at": "2026-05-21T01:26:22.205Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 54,
6
6
  "source_hashes": {
7
- "manifest.json": "d479c5acd441d98c8e4f75ab90366994affe4eea457f43b7180209f696af9ea2",
7
+ "manifest.json": "7ef8020cde7f0d25db4a7b3070f58e0bf385b01135040223788fcbe2e92b495c",
8
8
  "data/atlas-ttps.json": "d296c1d3e71807c9279b731f047e57796e85137f186586743a8cdad214b408f9",
9
9
  "data/attack-techniques.json": "49b6010b317edd219def135171ea8f3b1bbf1e00e9c5a08bf7237215ff54e2c3",
10
10
  "data/cve-catalog.json": "a09c83af3f9679a7ea73935726a1ff9de2cab94b4ab6321fc017fc147747d7c3",
@@ -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 };