@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 +16 -0
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/containers.js +403 -0
- package/lib/collectors/library-author.js +428 -0
- package/manifest.json +44 -44
- package/package.json +1 -1
- package/sbom.cdx.json +42 -12
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:
|
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-21T01:26:22.205Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 54,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
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 };
|