@blamejs/exceptd-skills 0.19.33 → 0.19.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.
Files changed (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -152
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +35 -114
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -412
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +50 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +13 -73
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +66 -197
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +3 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,19 +1,10 @@
1
1
  "use strict";
2
2
 
3
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.
4
+ * Companion collector for the `containers` playbook: walks the cwd for
5
+ * Dockerfile / Containerfile, compose and k8s manifest files and applies the
6
+ * catalogued indicator predicates. YAML is line-scanned, never parsed — every
7
+ * catalogued indicator is an unambiguous single-line pattern.
17
8
  *
18
9
  * Interface: see lib/collectors/README.md
19
10
  */
@@ -25,8 +16,7 @@ const { codeExcludeSet, walkTree, buildEvidenceLocations } = require("./scan-exc
25
16
  const COLLECTOR_ID = "containers";
26
17
 
27
18
  const DEFAULT_MAX_DEPTH = 6;
28
- // Shared code-scope exclusions (dependency caches, build output, VCS +
29
- // agent/editor scratch including `.claude/`); no container-specific extras.
19
+ // Shared code-scope exclusions; no container-specific extras.
30
20
  const DEFAULT_EXCLUDES = codeExcludeSet();
31
21
 
32
22
  const DOCKERFILE_NAMES = new Set(["Dockerfile", "Containerfile"]);
@@ -55,21 +45,14 @@ function classify(file) {
55
45
 
56
46
  function readSafe(full, max = 512 * 1024) {
57
47
  try {
58
- // Read raw bytes, enforce the cap on the buffer length, then decode.
59
- // Replaces a statSync-before-read with a single read; the cap is
60
- // byte-based, so Buffer.length is the correct measure and an oversized
61
- // file is rejected before any UTF-8 decode.
48
+ // Byte-based cap on Buffer.length: oversized files are rejected before decode.
62
49
  const raw = fs.readFileSync(full);
63
50
  if (raw.length > max) return null;
64
51
  return raw.toString("utf8");
65
52
  } catch { return null; }
66
53
  }
67
54
 
68
- /**
69
- * Recognise a YAML document as a k8s resource by `apiVersion: ...` +
70
- * `kind: ...` lines. Doesn't require full parsing — the two lines
71
- * appear at top-level indent.
72
- */
55
+ // Top-level `apiVersion:` + `kind:` is enough to recognise one without parsing.
73
56
  function looksLikeK8sManifest(content) {
74
57
  if (!/^apiVersion:\s+\S/m.test(content)) return false;
75
58
  if (!/^kind:\s+\S/m.test(content)) return false;
@@ -81,9 +64,8 @@ function extractKind(content) {
81
64
  return m ? m[1] : null;
82
65
  }
83
66
 
84
- /**
85
- * Dockerfile pattern matchers. Returns { id, hits: [{line, snippet}] }.
86
- */
67
+ // Returns { <indicator id>: [{ file, line, snippet }] }; line 0 means the hit
68
+ // is whole-file rather than a specific line.
87
69
  function scanDockerfile(content, rel) {
88
70
  const lines = content.split(/\r?\n/);
89
71
  const hits = {
@@ -93,12 +75,8 @@ function scanDockerfile(content, rel) {
93
75
  "dockerfile-curl-pipe-bash": [],
94
76
  };
95
77
 
96
- // Metadata-only Dockerfile heuristic — when the file has a FROM
97
- // line but no RUN / COPY / ADD / CMD / ENTRYPOINT / EXPOSE / VOLUME
98
- // / WORKDIR / USER directives, it's not a runtime image. Examples:
99
- // the go-version-scraping Dockerfile (cosign style) or a base-image-
100
- // probe used only by `docker build` to extract a version label.
101
- // The runs-as-root predicate is meaningless on those — demote.
78
+ // A FROM line with no execution-shape directive is a build-time probe, not a
79
+ // runtime image; the runs-as-root predicate means nothing there.
102
80
  const isMetadataOnly = (() => {
103
81
  let sawFrom = false;
104
82
  let sawBuildOrRuntime = false;
@@ -107,8 +85,7 @@ function scanDockerfile(content, rel) {
107
85
  if (!t || t.startsWith("#")) continue;
108
86
  if (/^FROM\b/i.test(t)) { sawFrom = true; continue; }
109
87
  if (/^(RUN|COPY|ADD|CMD|ENTRYPOINT|EXPOSE|VOLUME|WORKDIR|USER|HEALTHCHECK|ONBUILD|STOPSIGNAL|SHELL|ARG|ENV|LABEL)\b/i.test(t)) {
110
- // ARG / ENV / LABEL alone aren't enough to make this a runtime
111
- // image — only the directives that define execution shape do.
88
+ // ARG / ENV / LABEL alone do not make a runtime image.
112
89
  if (/^(RUN|COPY|ADD|CMD|ENTRYPOINT|EXPOSE|VOLUME|WORKDIR|USER|HEALTHCHECK|ONBUILD|SHELL)\b/i.test(t)) {
113
90
  sawBuildOrRuntime = true;
114
91
  break;
@@ -120,18 +97,12 @@ function scanDockerfile(content, rel) {
120
97
 
121
98
  let sawNonRootUser = false;
122
99
  let sawAnyUser = false;
123
- // Build-stage aliases declared via `FROM <image> AS <alias>`. A later
124
- // `FROM <alias> AS <next>` is an INTERNAL multi-stage reference, not a
125
- // registry image — it needs no tag/digest. Without tracking these, every
126
- // `FROM build-stage` in a normal multi-stage Dockerfile false-fired
127
- // from-latest / no-digest-pin.
100
+ // Aliases from `FROM <image> AS <alias>`. A later `FROM <alias>` is an internal
101
+ // multi-stage reference, not a registry image, and owes no tag or digest.
128
102
  const stageAliases = new Set();
129
- // ARG defaults declared earlier in the file, used to resolve interpolated
130
- // base references like `FROM node:${NODE_VERSION}` or `FROM ${BASE_IMAGE}`.
131
- // A digest pinned through an ARG default (`ARG BASE=img@sha256:...` then
132
- // `FROM ${BASE}`) is a legitimate pinning pattern that was invisible to the
133
- // raw-text check; and an unresolvable interpolation can't be proven to be
134
- // `:latest`, so flagging it from-latest was a false positive.
103
+ // ARG defaults, used to resolve `FROM node:${NODE_VERSION}`. A digest pinned
104
+ // through one is real pinning, and an unresolvable interpolation cannot be
105
+ // proven to be `:latest`, so neither may be flagged from raw text.
135
106
  const argDefaults = new Map();
136
107
  const resolveArgs = (s) => {
137
108
  let interpolatedUnresolved = false;
@@ -149,9 +120,7 @@ function scanDockerfile(content, rel) {
149
120
  const trimmed = line.trim();
150
121
  if (!trimmed || trimmed.startsWith("#")) continue;
151
122
 
152
- // Track `ARG NAME=default` (and bare `ARG NAME`) so a later FROM that
153
- // interpolates the name can resolve it. Quotes around the default are
154
- // stripped so `ARG BASE="img@sha256:..."` resolves cleanly.
123
+ // `ARG NAME=default` and bare `ARG NAME`; quotes come off the default.
155
124
  const argMatch = trimmed.match(/^ARG\s+([A-Za-z_][A-Za-z0-9_]*)(?:=(.*))?\s*$/i);
156
125
  if (argMatch) {
157
126
  const name = argMatch[1];
@@ -163,57 +132,39 @@ function scanDockerfile(content, rel) {
163
132
  continue;
164
133
  }
165
134
 
166
- // FROM <image>:latest OR FROM <image> (no tag) → from-latest
167
- // FROM <image>:<tag> (no @sha256:digest) → no-digest-pin
168
135
  const fromMatch = trimmed.match(/^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i);
169
136
  if (fromMatch) {
170
137
  const rawRef = fromMatch[1];
171
138
  const alias = fromMatch[2];
172
139
  const { resolved: ref, interpolatedUnresolved } = resolveArgs(rawRef);
173
- // Skip `scratch` (special base) AND references to a previously-declared
174
- // build stage (internal multi-stage reference — not a registry image, so
175
- // a tag/digest is neither present nor required).
140
+ // `scratch` and stage aliases are not registry images.
176
141
  if (ref !== "scratch" && !stageAliases.has(ref.toLowerCase())) {
177
142
  const hasDigest = /@sha256:[0-9a-f]{64}/i.test(ref);
178
- // When an interpolation could not be resolved, the actual image
179
- // reference is unknown — it may carry a digest supplied via
180
- // `--build-arg` at build time. Suppress both signals rather than
181
- // emit a guess; a resolvable ARG default still produces a precise
182
- // verdict below.
143
+ // An unresolved interpolation may carry a `--build-arg` digest: stay silent.
183
144
  if (!hasDigest && !interpolatedUnresolved) {
184
145
  hits["dockerfile-no-digest-pin"].push({ file: rel, line: i + 1, snippet: trimmed.slice(0, 120) });
185
146
  }
186
- // latest tag check: either explicit :latest OR no tag at all
187
- // (Docker defaults to :latest when omitted).
147
+ // No tag counts as :latest — that is Docker's default.
188
148
  const tagMatch = ref.match(/:([^@]+)(?:@|$)/);
189
149
  const tag = tagMatch ? tagMatch[1] : null;
190
150
  if (!interpolatedUnresolved && (tag === "latest" || tag === null)) {
191
151
  hits["dockerfile-from-latest"].push({ file: rel, line: i + 1, snippet: trimmed.slice(0, 120) });
192
152
  }
193
153
  }
194
- // USER does NOT carry across Docker build stages: each stage starts
195
- // from its base image's default user (root) until its own USER
196
- // directive. A `FROM <prior-stage-alias>` is the one exception — it
197
- // inherits the parent stage's USER, so it must NOT reset. Any other
198
- // FROM (registry image, or `scratch`) begins a fresh stage and resets
199
- // the per-stage USER tracking, so the runs-as-root predicate reflects
200
- // only the FINAL stage. Evaluate internal-vs-external against the
201
- // ARG-resolved `ref` and BEFORE the current alias is recorded.
154
+ // USER does not carry across build stages: a stage runs as its base image's
155
+ // user (root) until its own USER directive, so an external FROM resets the
156
+ // tracking. A `FROM <prior-stage-alias>` inherits and must not reset. Both
157
+ // tests read the ARG-resolved `ref`, before this stage's alias is recorded.
202
158
  const isInternalStageRef = stageAliases.has(ref.toLowerCase());
203
159
  if (!isInternalStageRef) {
204
160
  sawNonRootUser = false;
205
161
  sawAnyUser = false;
206
162
  }
207
- // Record this stage's alias so later `FROM <alias>` lines are recognized
208
- // as internal references.
209
163
  if (alias) stageAliases.add(alias.toLowerCase());
210
164
  continue;
211
165
  }
212
166
 
213
- // USER directive — looks for explicit non-root user.
214
- // Recognises USER <uid>, USER <uid>:<gid>, USER <name>, USER <name>:<group>.
215
- // Root forms (all count as root): "root", "0", "root:<anything>",
216
- // "0:<anything>". Any other UID/name is non-root.
167
+ // Root is "root" or "0", with any group suffix; every other uid is non-root.
217
168
  const userMatch = trimmed.match(/^USER\s+(\S+)/i);
218
169
  if (userMatch) {
219
170
  sawAnyUser = true;
@@ -223,7 +174,6 @@ function scanDockerfile(content, rel) {
223
174
  continue;
224
175
  }
225
176
 
226
- // curl|wget | sh|bash pattern
227
177
  if (
228
178
  /\b(?:curl|wget)\b[^|]*\|\s*(?:sh|bash|zsh)\b/.test(trimmed) ||
229
179
  /\b(?:curl|wget)\b[^&|;]*\s+&&\s+(?:sh|bash)\b/.test(trimmed)
@@ -232,10 +182,7 @@ function scanDockerfile(content, rel) {
232
182
  }
233
183
  }
234
184
 
235
- // runs-as-root indicator: file fires if NO non-root USER directive
236
- // was seen anywhere. (sawAnyUser=false also counts — image defaults
237
- // to root.) Demoted on metadata-only Dockerfiles (FROM-only, no
238
- // execution-shape directives) — those aren't runtime images.
185
+ // Fires when the final stage carries no non-root USER; none defaults to root.
239
186
  if (!sawNonRootUser && !isMetadataOnly) {
240
187
  hits["dockerfile-runs-as-root"].push({ file: rel, line: 0, snippet: sawAnyUser ? "USER directive sets root/0" : "no USER directive (defaults to root)" });
241
188
  }
@@ -243,11 +190,6 @@ function scanDockerfile(content, rel) {
243
190
  return hits;
244
191
  }
245
192
 
246
- /**
247
- * docker-compose pattern matchers. Heuristic: match `key: true` /
248
- * `key: <value>` lines, capturing the service block path where
249
- * possible.
250
- */
251
193
  function scanCompose(content, rel) {
252
194
  const lines = content.split(/\r?\n/);
253
195
  const hits = {
@@ -259,22 +201,17 @@ function scanCompose(content, rel) {
259
201
 
260
202
  for (let i = 0; i < lines.length; i++) {
261
203
  const line = lines[i];
262
- // A real compose line is never multiple KB. Skip overlong lines so a
263
- // crafted whitespace run can't drive regex backtracking.
204
+ // Skipping overlong lines keeps a crafted whitespace run from backtracking.
264
205
  if (line.length > 4096) continue;
265
206
  if (/^\s*#/.test(line)) continue;
266
207
  if (/^\s*privileged:\s*true\b/i.test(line)) hits["compose-privileged"].push({ file: rel, line: i + 1, snippet: line.trim() });
267
- // compose-host-network: per playbook, fires on any of
268
- // network_mode: host, pid: host, ipc: host.
208
+ // Per the playbook, any of network_mode / pid / ipc set to host.
269
209
  if (/^\s*network_mode:\s*['"]?host\b/i.test(line) ||
270
210
  /^\s*pid:\s*['"]?host['"]?\s*$/i.test(line) ||
271
211
  /^\s*ipc:\s*['"]?host['"]?\s*$/i.test(line)) {
272
212
  hits["compose-host-network"].push({ file: rel, line: i + 1, snippet: line.trim() });
273
213
  }
274
- // compose-cap-add-sys-admin: per playbook, fires on
275
- // SYS_ADMIN, SYS_PTRACE, or SYS_MODULE under cap_add. Either
276
- // inline (cap_add: [SYS_ADMIN, SYS_PTRACE]) or multi-line
277
- // (cap_add:\n - SYS_PTRACE).
214
+ // Per the playbook: risky caps under cap_add, inline list or `- ` items.
278
215
  const RISKY_CAPS_RE = /\b(?:CAP_)?(?:SYS_ADMIN|SYS_PTRACE|SYS_MODULE)\b/i;
279
216
  if (/cap_add:.*\[/i.test(line) && RISKY_CAPS_RE.test(line)) {
280
217
  hits["compose-cap-add-sys-admin"].push({ file: rel, line: i + 1, snippet: line.trim() });
@@ -282,7 +219,6 @@ function scanCompose(content, rel) {
282
219
  lines.slice(Math.max(0, i - 5), i).some(l => /cap_add:/i.test(l))) {
283
220
  hits["compose-cap-add-sys-admin"].push({ file: rel, line: i + 1, snippet: line.trim() });
284
221
  }
285
- // docker-sock mount: /var/run/docker.sock anywhere in the value
286
222
  if (/\/var\/run\/docker\.sock/.test(line)) {
287
223
  hits["compose-docker-sock-mount"].push({ file: rel, line: i + 1, snippet: line.trim() });
288
224
  }
@@ -290,10 +226,6 @@ function scanCompose(content, rel) {
290
226
  return hits;
291
227
  }
292
228
 
293
- /**
294
- * k8s manifest pattern matchers. Heuristic line-scanning + kind-
295
- * level guards (e.g. ClusterRoleBinding for cluster-admin check).
296
- */
297
229
  function scanK8s(content, rel) {
298
230
  const lines = content.split(/\r?\n/);
299
231
  const kind = extractKind(content);
@@ -308,36 +240,28 @@ function scanK8s(content, rel) {
308
240
 
309
241
  for (let i = 0; i < lines.length; i++) {
310
242
  const line = lines[i];
311
- // A real manifest line is never multiple KB. Skip overlong lines so a
312
- // crafted whitespace run can't drive regex backtracking.
243
+ // Skipping overlong lines keeps a crafted whitespace run from backtracking.
313
244
  if (line.length > 4096) continue;
314
245
  if (/^\s*#/.test(line)) continue;
315
246
  if (/^\s*privileged:\s*true\b/i.test(line)) hits["k8s-privileged"].push({ file: rel, line: i + 1, snippet: line.trim() });
316
247
  if (/^\s*(hostNetwork|hostPID|hostIPC):\s*true\b/.test(line)) hits["k8s-host-namespaces"].push({ file: rel, line: i + 1, snippet: line.trim() });
317
- // k8s-run-as-root: per playbook, fires on runAsUser: 0 OR
318
- // runAsNonRoot: false. (The "runAsUser unset AND image runs as
319
- // root" clause requires image inspection the collector can't do
320
- // without a container runtime; leave that to the runner when
321
- // operator-supplied image-inspection evidence is available.)
248
+ // Only the two clauses visible in the manifest; the playbook's third needs a
249
+ // container runtime, so it stays with the runner.
322
250
  if (/^\s*runAsUser:\s*0\b/.test(line) ||
323
251
  /^\s*runAsNonRoot:\s*false\b/.test(line)) {
324
252
  hits["k8s-run-as-root"].push({ file: rel, line: i + 1, snippet: line.trim() });
325
253
  }
326
- // hostPath sensitive: /, /etc, /var/lib/docker, /proc, /sys
327
254
  const hpMatch = line.match(/^\s*path:\s*['"]?(\/(?:etc|proc|sys|var\/lib\/docker|var\/run|root|home)?\/?)['"]?\s*$/);
328
255
  if (hpMatch && lines.slice(Math.max(0, i - 3), i).some(l => /hostPath:/i.test(l))) {
329
256
  hits["k8s-hostpath-sensitive"].push({ file: rel, line: i + 1, snippet: line.trim() });
330
257
  }
331
- // image: ...:latest OR image: ... (no tag, defaults to latest)
332
- // Allow optional leading `-` from a YAML list item: `- image: ...`.
333
- // `^[ \t]*(?:-[ \t]*)?` anchors the indentation once, then an optional
334
- // `- ` list marker — no overlapping `\s*` runs that backtrack.
258
+ // Indentation and the `- ` marker are anchored once each: no overlapping runs.
335
259
  const imageMatch = line.match(/^[ \t]*(?:-[ \t]*)?image:\s*['"]?([^'"@\s]+)(?:@[^'"]+)?['"]?\s*$/);
336
260
  if (imageMatch) {
337
261
  const ref = imageMatch[1];
338
262
  const tagMatch = ref.match(/:([^/]+)$/);
339
263
  const tag = tagMatch ? tagMatch[1] : null;
340
- // Skip if @sha256:... pinned (the full regex captured ref without @-suffix).
264
+ // Tested against the whole line: the capture above drops the @-suffix.
341
265
  const hasDigest = /@sha256:[0-9a-f]{64}/.test(line);
342
266
  if (!hasDigest && (tag === "latest" || tag === null)) {
343
267
  hits["k8s-image-latest"].push({ file: rel, line: i + 1, snippet: line.trim() });
@@ -345,7 +269,6 @@ function scanK8s(content, rel) {
345
269
  }
346
270
  }
347
271
 
348
- // ClusterRoleBinding referencing cluster-admin
349
272
  if (kind === "ClusterRoleBinding" || kind === "RoleBinding") {
350
273
  if (/name:\s*['"]?cluster-admin['"]?/m.test(content)) {
351
274
  hits["k8s-cluster-admin-binding"].push({ file: rel, line: 0, snippet: `${kind} binds cluster-admin` });
@@ -377,7 +300,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
377
300
  }
378
301
  }
379
302
 
380
- // Aggregate hits per indicator.
381
303
  const allHits = {};
382
304
  for (const id of [
383
305
  "dockerfile-from-latest", "dockerfile-no-digest-pin", "dockerfile-runs-as-root", "dockerfile-curl-pipe-bash",
@@ -409,21 +331,14 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
409
331
  for (const [id, list] of Object.entries(fileHits)) allHits[id].push(...list);
410
332
  }
411
333
 
412
- // signal_overrides — flip every indicator the collector can decide
413
- // deterministically. (k8s-no-seccomp-profile / psa-policy-* /
414
- // network-policies-absent require cluster-API access the collector
415
- // doesn't have; leave them unflipped so the runner returns
416
- // inconclusive rather than a forced miss.)
334
+ // Only the indicators decidable from files on disk; the ones needing
335
+ // cluster-API access stay unflipped, so the runner returns inconclusive.
417
336
  const signal_overrides = {};
418
337
  for (const [id, list] of Object.entries(allHits)) {
419
338
  signal_overrides[id] = list.length > 0 ? "hit" : "miss";
420
339
  }
421
340
 
422
- // Per-indicator file locations for every indicator flipped to "hit", so a
423
- // SARIF result points at the Dockerfile / compose / k8s manifest line that
424
- // triggered it. Line-scanned hits carry a real line number (emitted as
425
- // startLine); whole-file hits (e.g. runs-as-root, cluster-admin-binding)
426
- // record line 0 and surface as file-level locations.
341
+ // Line 0 surfaces as a file-level SARIF location rather than a startLine.
427
342
  const evidence_locations = {};
428
343
  for (const [id, list] of Object.entries(allHits)) {
429
344
  if (signal_overrides[id] === "hit") {
@@ -490,13 +405,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
490
405
  };
491
406
  }
492
407
 
493
- // Shared with `discover`: the relative paths of container artifacts
494
- // (Dockerfiles / Containerfiles / compose files, including variants like
495
- // docker-compose.test.yml or examples/wiki/Dockerfile) anywhere in the bounded
496
- // tree this playbook walks — so `discover` recommends `containers` for exactly
497
- // the surface the collector scans, not just a root-level exact-name Dockerfile
498
- // or docker-compose.yml. Filename-only (no content reads), capped, and
499
- // fault-tolerant so a walk error can't break discovery.
408
+ // `discover` calls this to decide whether to recommend `containers`, so it walks
409
+ // the same bounded tree and classifier the collector does. Filenames only,
410
+ // capped, and swallows a walk error so discovery cannot break on one.
500
411
  function hasContainerArtifacts(cwd, { cap = 8 } = {}) {
501
412
  let files;
502
413
  try { files = walkTree(path.resolve(cwd || "."), { maxDepth: DEFAULT_MAX_DEPTH, excludes: DEFAULT_EXCLUDES }); }