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