security-mcp 1.3.1 → 1.3.3

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 (131) hide show
  1. package/README.md +356 -885
  2. package/defaults/cloud-controls/aws.json +10712 -0
  3. package/defaults/cloud-controls/azure.json +7201 -0
  4. package/defaults/cloud-controls/gcp.json +4061 -0
  5. package/defaults/control-catalog.json +24 -0
  6. package/dist/ci/pr-gate.js +22 -5
  7. package/dist/cli/index.js +73 -2
  8. package/dist/cli/install.js +4 -55
  9. package/dist/cli/onboarding.js +18 -10
  10. package/dist/gate/checks/agentic-instructions.js +515 -0
  11. package/dist/gate/checks/ai-governance.js +132 -0
  12. package/dist/gate/checks/ai.js +1 -1
  13. package/dist/gate/checks/cloud-controls.js +69 -0
  14. package/dist/gate/checks/crypto.js +1 -1
  15. package/dist/gate/checks/data-platform.js +954 -0
  16. package/dist/gate/checks/dependencies.js +14 -3
  17. package/dist/gate/checks/docker-deep.js +1236 -0
  18. package/dist/gate/checks/gitops.js +724 -0
  19. package/dist/gate/checks/iac.js +1230 -0
  20. package/dist/gate/checks/k8s.js +841 -1
  21. package/dist/gate/checks/secrets.js +49 -37
  22. package/dist/gate/cloud-controls/apply.js +115 -0
  23. package/dist/gate/cloud-controls/bicep.js +36 -0
  24. package/dist/gate/cloud-controls/cfn.js +125 -0
  25. package/dist/gate/cloud-controls/detect.js +104 -0
  26. package/dist/gate/cloud-controls/hcl.js +140 -0
  27. package/dist/gate/cloud-controls/types.js +87 -0
  28. package/dist/gate/exceptions.js +78 -7
  29. package/dist/gate/findings.js +15 -2
  30. package/dist/gate/policy.js +40 -3
  31. package/dist/gate/threat-intel.js +6 -0
  32. package/dist/mcp/audit-chain.js +9 -0
  33. package/dist/mcp/model-router.js +3 -3
  34. package/dist/mcp/orchestration.js +194 -41
  35. package/dist/mcp/server.js +124 -17
  36. package/dist/mcp/tool-audit.js +193 -0
  37. package/dist/repo/fs.js +14 -1
  38. package/dist/review/store.js +4 -2
  39. package/dist/tests/run.js +124 -1
  40. package/package.json +3 -3
  41. package/skills/advanced-dos-tester/SKILL.md +9 -0
  42. package/skills/agentic-instruction-auditor/SKILL.md +111 -0
  43. package/skills/agentic-loop-exploiter/SKILL.md +9 -0
  44. package/skills/ai-llm-redteam/SKILL.md +9 -0
  45. package/skills/ai-model-supply-chain-agent/SKILL.md +9 -0
  46. package/skills/algorithm-implementation-reviewer/SKILL.md +9 -0
  47. package/skills/android-penetration-tester/SKILL.md +9 -0
  48. package/skills/anti-replay-tester/SKILL.md +9 -0
  49. package/skills/appsec-code-auditor/SKILL.md +9 -0
  50. package/skills/artifact-integrity-analyst/SKILL.md +9 -0
  51. package/skills/attack-navigator/SKILL.md +9 -0
  52. package/skills/auth-session-hacker/SKILL.md +9 -0
  53. package/skills/aws-penetration-tester/SKILL.md +54 -0
  54. package/skills/azure-penetration-tester/SKILL.md +52 -0
  55. package/skills/binary-auth-validator/SKILL.md +9 -0
  56. package/skills/bot-detection-specialist/SKILL.md +9 -0
  57. package/skills/business-logic-attacker/SKILL.md +9 -0
  58. package/skills/capec-code-mapper/SKILL.md +9 -0
  59. package/skills/cert-pin-rotation-specialist/SKILL.md +9 -0
  60. package/skills/cicd-pipeline-hijacker/SKILL.md +9 -0
  61. package/skills/ciso-orchestrator/SKILL.md +11 -0
  62. package/skills/cloud-infra-specialist/SKILL.md +9 -0
  63. package/skills/compliance-gap-analyst/SKILL.md +9 -0
  64. package/skills/compliance-grc/SKILL.md +9 -0
  65. package/skills/compliance-lifecycle-tracker/SKILL.md +9 -0
  66. package/skills/container-hardening-auditor/SKILL.md +125 -0
  67. package/skills/credential-stuffing-specialist/SKILL.md +9 -0
  68. package/skills/crypto-pki-specialist/SKILL.md +9 -0
  69. package/skills/csa-ccm-mapper/SKILL.md +9 -0
  70. package/skills/csf2-governance-mapper/SKILL.md +9 -0
  71. package/skills/data-platform-auditor/SKILL.md +125 -0
  72. package/skills/deep-link-fuzzer/SKILL.md +9 -0
  73. package/skills/dependency-confusion-attacker/SKILL.md +9 -0
  74. package/skills/device-integrity-aggregator/SKILL.md +9 -0
  75. package/skills/dos-resilience-tester/SKILL.md +9 -0
  76. package/skills/dread-scorer/SKILL.md +9 -0
  77. package/skills/egress-policy-enforcer/SKILL.md +9 -0
  78. package/skills/evidence-collector/SKILL.md +9 -0
  79. package/skills/file-upload-attacker/SKILL.md +9 -0
  80. package/skills/gcp-penetration-tester/SKILL.md +51 -0
  81. package/skills/git-history-secret-scanner/SKILL.md +9 -0
  82. package/skills/gitops-delivery-auditor/SKILL.md +120 -0
  83. package/skills/iac-security-auditor/SKILL.md +125 -0
  84. package/skills/iam-privesc-graph-builder/SKILL.md +9 -0
  85. package/skills/incident-responder/SKILL.md +9 -0
  86. package/skills/injection-specialist/SKILL.md +9 -0
  87. package/skills/ios-security-auditor/SKILL.md +9 -0
  88. package/skills/json-ambiguity-tester/SKILL.md +0 -0
  89. package/skills/k8s-container-escaper/SKILL.md +22 -0
  90. package/skills/key-management-lifecycle-analyst/SKILL.md +9 -0
  91. package/skills/kill-switch-engineer/SKILL.md +9 -0
  92. package/skills/linddun-privacy-analyst/SKILL.md +9 -0
  93. package/skills/logic-race-fuzzer/SKILL.md +9 -0
  94. package/skills/mobile-api-network-attacker/SKILL.md +9 -0
  95. package/skills/mobile-binary-hardener/SKILL.md +9 -0
  96. package/skills/mobile-security-specialist/SKILL.md +9 -0
  97. package/skills/mobile-webview-auditor/SKILL.md +9 -0
  98. package/skills/model-extraction-attacker/SKILL.md +9 -0
  99. package/skills/multipart-abuse-tester/SKILL.md +9 -0
  100. package/skills/oauth-pkce-specialist/SKILL.md +9 -0
  101. package/skills/parser-exhaustion-tester/SKILL.md +9 -0
  102. package/skills/pentest-infra/SKILL.md +9 -0
  103. package/skills/pentest-social/SKILL.md +9 -0
  104. package/skills/pentest-team/SKILL.md +9 -0
  105. package/skills/pentest-web-api/SKILL.md +9 -0
  106. package/skills/privacy-flow-analyst/SKILL.md +9 -0
  107. package/skills/prompt-injection-specialist/SKILL.md +9 -0
  108. package/skills/quantum-migration-planner/SKILL.md +9 -0
  109. package/skills/rag-poisoning-specialist/SKILL.md +9 -0
  110. package/skills/registry-mirror-enforcer/SKILL.md +9 -0
  111. package/skills/rotation-validation-agent/SKILL.md +9 -0
  112. package/skills/samm-assessor/SKILL.md +9 -0
  113. package/skills/secrets-mask-bypass-tester/SKILL.md +9 -0
  114. package/skills/senior-security-engineer/SKILL.md +11 -0
  115. package/skills/serialization-memory-attacker/SKILL.md +9 -0
  116. package/skills/session-timeout-tester/SKILL.md +9 -0
  117. package/skills/slsa-level3-enforcer/SKILL.md +9 -0
  118. package/skills/slsa-provenance-enforcer/SKILL.md +9 -0
  119. package/skills/ssrf-detection-validator/SKILL.md +9 -0
  120. package/skills/step-up-auth-enforcer/SKILL.md +9 -0
  121. package/skills/stride-pasta-analyst/SKILL.md +9 -0
  122. package/skills/supply-chain-devsecops/SKILL.md +9 -0
  123. package/skills/threat-infrastructure-analyst/SKILL.md +9 -0
  124. package/skills/threat-modeler/SKILL.md +9 -0
  125. package/skills/tls-certificate-auditor/SKILL.md +9 -0
  126. package/skills/token-reuse-detector/SKILL.md +9 -0
  127. package/skills/trike-risk-modeler/SKILL.md +9 -0
  128. package/skills/unicode-homograph-tester/SKILL.md +9 -0
  129. package/skills/waf-rule-lifecycle-agent/SKILL.md +9 -0
  130. package/skills/webhook-security-tester/SKILL.md +9 -0
  131. package/skills/zero-trust-architect/SKILL.md +9 -0
@@ -0,0 +1,1236 @@
1
+ import { searchRepo } from "../../repo/search.js";
2
+ import fg from "fast-glob";
3
+ import { readFileSafe } from "../../repo/fs.js";
4
+ // Structural scan: detect secret-looking entries inside a docker-compose `labels:`
5
+ // block. Per-line regex can't see the parent `labels:` key, so it cannot tell a
6
+ // label secret apart from an environment variable. This loads compose files and
7
+ // tracks indentation to scope the match to label entries only, and redacts the
8
+ // value so the secret is never echoed into a finding (CWE-200).
9
+ const LABEL_SECRET_RE = /(password|passwd|secret|token|api[_-]?key|apikey|private[_-]?key)/i;
10
+ function redactKv(line) {
11
+ return line.replace(/([:=]\s*).*/, "$1[REDACTED]").trim().slice(0, 120);
12
+ }
13
+ async function scanComposeLabelSecrets() {
14
+ const files = await fg(["**/*compose*.{yml,yaml}"], {
15
+ dot: true,
16
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"],
17
+ followSymbolicLinks: false,
18
+ });
19
+ const hits = [];
20
+ for (const file of files) {
21
+ let text = "";
22
+ try {
23
+ text = await readFileSafe(file);
24
+ }
25
+ catch {
26
+ continue;
27
+ }
28
+ const lines = text.split(/\r?\n/);
29
+ let labelIndent = -1;
30
+ for (let i = 0; i < lines.length && hits.length < 50; i++) {
31
+ const line = lines[i];
32
+ const indent = line.search(/\S/);
33
+ if (indent === -1)
34
+ continue;
35
+ // Inline forms: `labels: {a: secret}` or `labels: ["a=secret"]` on one line.
36
+ if (/^\s*labels:\s*[[{]/.test(line)) {
37
+ if (LABEL_SECRET_RE.test(line))
38
+ hits.push({ file, line: i + 1, preview: redactKv(line) });
39
+ continue;
40
+ }
41
+ // Block form: `labels:` on its own line; children are more-indented.
42
+ if (/^\s*labels:\s*$/.test(line)) {
43
+ labelIndent = indent;
44
+ continue;
45
+ }
46
+ if (labelIndent >= 0) {
47
+ if (indent <= labelIndent) {
48
+ labelIndent = -1; // dedent — block ended
49
+ }
50
+ else if (LABEL_SECRET_RE.test(line)) {
51
+ hits.push({ file, line: i + 1, preview: redactKv(line) });
52
+ }
53
+ }
54
+ }
55
+ }
56
+ return hits;
57
+ }
58
+ // Deep container-security checks. Extends src/gate/checks/runtime.ts.
59
+ // Does NOT re-implement: DOCKER_NO_USER_DIRECTIVE, DOCKER_ADD_REMOTE_URL,
60
+ // DOCKER_SECRETS_IN_ENV, DOCKER_PRIVILEGED_FLAG, DOCKER_SOCKET_MOUNT.
61
+ //
62
+ // Each searchRepo regex is < 256 chars, has no nested quantifiers, and uses
63
+ // String.raw for backslashes to satisfy the ReDoS guard in repo/search.js.
64
+ const MAX = 50;
65
+ const DOCKERFILE_RE = String.raw `(?:dockerfile|\.dockerfile)`;
66
+ const COMPOSE_RE = String.raw `docker-compose.*\.ya?ml`;
67
+ function isDockerfile(file) {
68
+ return /(^|\/)dockerfile($|\.)/i.test(file) || /\.dockerfile$/i.test(file);
69
+ }
70
+ function isCompose(file) {
71
+ return /docker-compose.*\.ya?ml$/i.test(file);
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // 1. Unpinned base image (no digest pin / :latest / no tag)
75
+ // ---------------------------------------------------------------------------
76
+ async function checkUnpinnedBaseImage() {
77
+ // FROM lines with :latest or with NO tag at all (HIGH).
78
+ const latestOrNoTag = await searchRepo({
79
+ query: String.raw `^\s*FROM\s+\S+:latest(\s|$)`,
80
+ isRegex: true,
81
+ maxMatches: MAX,
82
+ });
83
+ // FROM <image> with no ":" tag and no "@sha256" — bare image name.
84
+ // Tail [\sa-z0-9]* tolerates an optional "AS stage" without a quantified group.
85
+ const bareImage = await searchRepo({
86
+ query: String.raw `^\s*FROM\s+[a-z0-9./_-]+[\sa-z0-9]*$`,
87
+ isRegex: true,
88
+ maxMatches: MAX,
89
+ });
90
+ // FROM image:tag WITHOUT @sha256 digest (MEDIUM): has a colon tag, no digest.
91
+ const tagNoDigest = await searchRepo({
92
+ query: String.raw `^\s*FROM\s+\S+:[a-z0-9._-]+[\sa-z0-9]*$`,
93
+ isRegex: true,
94
+ maxMatches: MAX,
95
+ });
96
+ const findings = [];
97
+ const highMatches = [...latestOrNoTag, ...bareImage].filter((m) => {
98
+ if (!isDockerfile(m.file))
99
+ return false;
100
+ return !/@sha256:/i.test(m.preview);
101
+ });
102
+ if (highMatches.length > 0) {
103
+ findings.push({
104
+ id: "DOCKER_BASE_IMAGE_UNPINNED",
105
+ title: "Dockerfile base image uses :latest or no tag — not pinned to a digest, allowing supply-chain image swap (CWE-1357)",
106
+ severity: "HIGH",
107
+ evidence: highMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
108
+ requiredActions: [
109
+ "Pin the base image to an immutable digest: FROM image:tag@sha256:<digest>.",
110
+ "Never use :latest or an untagged image; resolve and lock the digest in CI and update it deliberately.",
111
+ ],
112
+ });
113
+ }
114
+ const mediumMatches = tagNoDigest.filter((m) => {
115
+ if (!isDockerfile(m.file))
116
+ return false;
117
+ if (/@sha256:/i.test(m.preview))
118
+ return false;
119
+ if (/:latest(\s|$)/i.test(m.preview))
120
+ return false; // already HIGH
121
+ return true;
122
+ });
123
+ if (mediumMatches.length > 0) {
124
+ findings.push({
125
+ id: "DOCKER_BASE_IMAGE_NO_DIGEST",
126
+ title: "Dockerfile base image pinned by tag but not by @sha256 digest — tag is mutable and can be repointed upstream (CWE-1357)",
127
+ severity: "MEDIUM",
128
+ evidence: mediumMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
129
+ requiredActions: [
130
+ "Append the content digest to the base image: FROM image:tag@sha256:<digest>.",
131
+ "Automate digest pinning and verification in your build pipeline.",
132
+ ],
133
+ });
134
+ }
135
+ return findings;
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // 2. Remote pipe-to-shell inside RUN (curl|sh, wget|bash)
139
+ // ---------------------------------------------------------------------------
140
+ async function checkPipeToShell() {
141
+ const curlPipe = await searchRepo({
142
+ query: String.raw `(?:curl|wget)\s[^|]*https?://[^|]*\|[^\n]*(?:sh|bash)\b`,
143
+ isRegex: true,
144
+ maxMatches: MAX,
145
+ });
146
+ const matches = curlPipe.filter((m) => isDockerfile(m.file) || isCompose(m.file));
147
+ if (matches.length === 0)
148
+ return [];
149
+ return [{
150
+ id: "DOCKER_RUN_PIPE_TO_SHELL",
151
+ title: "RUN pipes a remote download directly into a shell (curl|sh / wget|bash) — unverified remote code execution at build time (CWE-494)",
152
+ severity: "HIGH",
153
+ evidence: matches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
154
+ requiredActions: [
155
+ "Replace curl|bash with a verified download: fetch to a file, verify a published sha256 checksum or GPG signature, then execute.",
156
+ "Pin the script/installer version and review it; never execute remote content fetched at build time without integrity verification.",
157
+ ],
158
+ }];
159
+ }
160
+ // ---------------------------------------------------------------------------
161
+ // 3. sudo usage / chmod 777
162
+ // ---------------------------------------------------------------------------
163
+ async function checkSudoAnd777() {
164
+ const findings = [];
165
+ const sudo = await searchRepo({
166
+ query: String.raw `^\s*RUN\s+.*(?:\bsudo\b|install[^\n]*\bsudo\b)`,
167
+ isRegex: true,
168
+ maxMatches: MAX,
169
+ });
170
+ const sudoMatches = sudo.filter((m) => isDockerfile(m.file));
171
+ if (sudoMatches.length > 0) {
172
+ findings.push({
173
+ id: "DOCKER_RUN_SUDO",
174
+ title: "RUN uses or installs sudo inside the image — defeats least-privilege and enables in-container privilege escalation (CWE-250)",
175
+ severity: "MEDIUM",
176
+ evidence: sudoMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
177
+ requiredActions: [
178
+ "Remove sudo from the image; perform privileged build steps before dropping to a non-root USER.",
179
+ "Do not install the sudo package in container images; run the workload as a dedicated low-privilege user.",
180
+ ],
181
+ });
182
+ }
183
+ const chmod777 = await searchRepo({
184
+ query: String.raw `\bchmod\s[-a-zA-Z0-9\s]*0?777\b`,
185
+ isRegex: true,
186
+ maxMatches: MAX,
187
+ });
188
+ const chmodMatches = chmod777.filter((m) => isDockerfile(m.file) || isCompose(m.file));
189
+ if (chmodMatches.length > 0) {
190
+ findings.push({
191
+ id: "DOCKER_CHMOD_777",
192
+ title: "chmod 777 grants world-writable permissions — any user/process can modify these files, enabling tampering (CWE-732)",
193
+ severity: "MEDIUM",
194
+ evidence: chmodMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
195
+ requiredActions: [
196
+ "Replace chmod 777 with the minimal permissions required (e.g. 755 for executables, 644 for data).",
197
+ "Set ownership with chown to the runtime user instead of opening permissions to everyone.",
198
+ ],
199
+ });
200
+ }
201
+ return findings;
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // 4. Missing HEALTHCHECK (per Dockerfile with FROM, no HEALTHCHECK)
205
+ // ---------------------------------------------------------------------------
206
+ async function checkMissingHealthcheck() {
207
+ const froms = await searchRepo({
208
+ query: String.raw `^\s*FROM\s+\S`,
209
+ isRegex: true,
210
+ maxMatches: MAX,
211
+ });
212
+ const healthchecks = await searchRepo({
213
+ query: String.raw `^\s*HEALTHCHECK\s`,
214
+ isRegex: true,
215
+ maxMatches: MAX,
216
+ });
217
+ const dockerfilesWithFrom = new Set(froms.filter((m) => isDockerfile(m.file)).map((m) => m.file));
218
+ const dockerfilesWithHc = new Set(healthchecks.filter((m) => isDockerfile(m.file)).map((m) => m.file));
219
+ const offending = [...dockerfilesWithFrom].filter((f) => !dockerfilesWithHc.has(f));
220
+ if (offending.length === 0)
221
+ return [];
222
+ return [{
223
+ id: "DOCKER_NO_HEALTHCHECK",
224
+ title: "Dockerfile defines no HEALTHCHECK — orchestrators cannot detect a hung/compromised container and route traffic away",
225
+ severity: "LOW",
226
+ files: offending.slice(0, 12),
227
+ requiredActions: [
228
+ "Add a HEALTHCHECK instruction that verifies the application is actually serving (e.g. HEALTHCHECK CMD curl -f http://localhost:PORT/health || exit 1).",
229
+ "Ensure the orchestrator (Compose/Kubernetes) is configured to act on the health status.",
230
+ ],
231
+ }];
232
+ }
233
+ // ---------------------------------------------------------------------------
234
+ // 5. ADD of local archive, or COPY . . / ADD . copying whole build context
235
+ // ---------------------------------------------------------------------------
236
+ async function checkBroadCopyAndArchive() {
237
+ const findings = [];
238
+ // ADD local-archive (not a URL): .tar/.tar.gz/.tgz/.zip
239
+ const addArchive = await searchRepo({
240
+ query: String.raw `^\s*ADD\s+(?!https?://)\S+\.(?:tar|tar\.gz|tgz|zip|tar\.bz2)\b`,
241
+ isRegex: true,
242
+ maxMatches: MAX,
243
+ });
244
+ const archiveMatches = addArchive.filter((m) => isDockerfile(m.file));
245
+ if (archiveMatches.length > 0) {
246
+ findings.push({
247
+ id: "DOCKER_ADD_LOCAL_ARCHIVE",
248
+ title: "ADD auto-extracts a local archive — implicit, unverified extraction can enable path traversal/zip-slip; use COPY (CWE-22)",
249
+ severity: "MEDIUM",
250
+ evidence: archiveMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
251
+ requiredActions: [
252
+ "Use COPY for local files; if extraction is needed, run a verified RUN tar/unzip step with explicit, audited paths.",
253
+ "Verify the archive's checksum before extracting and avoid ADD's implicit tar extraction behavior.",
254
+ ],
255
+ });
256
+ }
257
+ // COPY . . or ADD . — whole build context (leaks .git, secrets, node_modules)
258
+ const broadCopy = await searchRepo({
259
+ query: String.raw `^\s*(?:COPY|ADD)\s+\.\s+(?:\.|\./|/)`,
260
+ isRegex: true,
261
+ maxMatches: MAX,
262
+ });
263
+ const broadMatches = broadCopy.filter((m) => isDockerfile(m.file));
264
+ if (broadMatches.length > 0) {
265
+ findings.push({
266
+ id: "DOCKER_COPY_WHOLE_CONTEXT",
267
+ title: "COPY . . / ADD . copies the entire build context into the image — leaks .git, .env, keys and source not needed at runtime (CWE-538)",
268
+ severity: "MEDIUM",
269
+ evidence: broadMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
270
+ requiredActions: [
271
+ "Copy only the specific files/directories required at runtime instead of the whole context.",
272
+ "Add a comprehensive .dockerignore (.git, .env, secrets, node_modules, tests) to prevent accidental inclusion.",
273
+ ],
274
+ });
275
+ }
276
+ return findings;
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ // 6. apt-get install -y without --no-install-recommends
280
+ // ---------------------------------------------------------------------------
281
+ async function checkAptNoRecommends() {
282
+ const apt = await searchRepo({
283
+ query: String.raw `apt-get\s+install\s[-a-zA-Z0-9\s]*-y\b`,
284
+ isRegex: true,
285
+ maxMatches: MAX,
286
+ });
287
+ const matches = apt.filter((m) => isDockerfile(m.file) && !/--no-install-recommends/i.test(m.preview));
288
+ if (matches.length === 0)
289
+ return [];
290
+ return [{
291
+ id: "DOCKER_APT_RECOMMENDS",
292
+ title: "apt-get install -y without --no-install-recommends — pulls extra packages, enlarging the image and attack surface",
293
+ severity: "LOW",
294
+ evidence: matches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
295
+ requiredActions: [
296
+ "Add --no-install-recommends to apt-get install and pin exact package versions.",
297
+ "Clean apt lists in the same layer (rm -rf /var/lib/apt/lists/*) to minimize image size and attack surface.",
298
+ ],
299
+ }];
300
+ }
301
+ // ---------------------------------------------------------------------------
302
+ // 7. docker-compose dangerous host/kernel exposures
303
+ // ---------------------------------------------------------------------------
304
+ async function checkComposeCapabilities() {
305
+ const findings = [];
306
+ const dangerousCap = await searchRepo({
307
+ query: String.raw `-\s*(?:SYS_ADMIN|NET_ADMIN|ALL|SYS_PTRACE|SYS_MODULE)\b`,
308
+ isRegex: true,
309
+ maxMatches: MAX,
310
+ });
311
+ const capMatches = dangerousCap.filter((m) => isCompose(m.file));
312
+ if (capMatches.length > 0) {
313
+ findings.push({
314
+ id: "DOCKER_COMPOSE_DANGEROUS_CAP",
315
+ title: "docker-compose cap_add grants dangerous Linux capability (SYS_ADMIN/NET_ADMIN/ALL) — enables container escape (CWE-250)",
316
+ severity: "HIGH",
317
+ evidence: capMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
318
+ requiredActions: [
319
+ "Drop all capabilities (cap_drop: [ALL]) and add back only the specific ones the workload needs; never SYS_ADMIN or ALL.",
320
+ "Re-architect the workload so it does not require kernel-level capabilities.",
321
+ ],
322
+ });
323
+ }
324
+ const seccompUnconfined = await searchRepo({
325
+ query: String.raw `(?:seccomp|apparmor)\s*[:=]\s*unconfined`,
326
+ isRegex: true,
327
+ maxMatches: MAX,
328
+ });
329
+ const seccompMatches = seccompUnconfined.filter((m) => isCompose(m.file));
330
+ if (seccompMatches.length > 0) {
331
+ findings.push({
332
+ id: "DOCKER_COMPOSE_UNCONFINED",
333
+ title: "docker-compose security_opt disables seccomp/apparmor (unconfined) — removes the syscall sandbox protecting the host (CWE-693)",
334
+ severity: "HIGH",
335
+ evidence: seccompMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
336
+ requiredActions: [
337
+ "Remove seccomp:unconfined / apparmor:unconfined; keep the default profiles enabled.",
338
+ "If a specific syscall is needed, supply a tailored seccomp profile rather than disabling it entirely.",
339
+ ],
340
+ });
341
+ }
342
+ const hostNamespace = await searchRepo({
343
+ query: String.raw `^\s*(?:pid|ipc|network_mode|userns_mode|uts)\s*:\s*["']?host\b`,
344
+ isRegex: true,
345
+ maxMatches: MAX,
346
+ });
347
+ const nsMatches = hostNamespace.filter((m) => isCompose(m.file));
348
+ if (nsMatches.length > 0) {
349
+ findings.push({
350
+ id: "DOCKER_COMPOSE_HOST_NAMESPACE",
351
+ title: "docker-compose shares a host namespace (pid/network/ipc/userns: host) — breaks container isolation from the host (CWE-668)",
352
+ severity: "HIGH",
353
+ evidence: nsMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
354
+ requiredActions: [
355
+ "Remove host namespace sharing (pid/ipc/uts/userns: host, network_mode: host); use bridge networking and isolated namespaces.",
356
+ "If host network access is required, expose only the specific ports needed via the ports: mapping instead.",
357
+ ],
358
+ });
359
+ }
360
+ return findings;
361
+ }
362
+ // ---------------------------------------------------------------------------
363
+ // 8. Exposed Docker daemon TCP / 0.0.0.0 binding
364
+ // ---------------------------------------------------------------------------
365
+ async function checkDaemonExposure() {
366
+ const findings = [];
367
+ const daemonPort = await searchRepo({
368
+ query: String.raw `(?::|")(?:2375|2376)(?::|")`,
369
+ isRegex: true,
370
+ maxMatches: MAX,
371
+ });
372
+ const daemonAltPort = await searchRepo({
373
+ query: String.raw `tcp://[^:\s]*:(?:2375|2376)\b`,
374
+ isRegex: true,
375
+ maxMatches: MAX,
376
+ });
377
+ const daemonMatches = [...daemonPort, ...daemonAltPort].filter((m) => isCompose(m.file) || isDockerfile(m.file));
378
+ if (daemonMatches.length > 0) {
379
+ findings.push({
380
+ id: "DOCKER_DAEMON_TCP_EXPOSED",
381
+ title: "Docker daemon TCP port (2375/2376) exposed — an unauthenticated daemon socket gives full host root control (CWE-306)",
382
+ severity: "CRITICAL",
383
+ evidence: daemonMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
384
+ requiredActions: [
385
+ "Never expose the Docker daemon over TCP; use the local unix socket with restricted permissions.",
386
+ "If remote access is unavoidable, require mutual TLS (2376) with client certificate auth and firewall the port to known hosts.",
387
+ ],
388
+ });
389
+ }
390
+ const bindAll = await searchRepo({
391
+ query: String.raw `^\s*-\s*["']?0\.0\.0\.0:\d+:\d+`,
392
+ isRegex: true,
393
+ maxMatches: MAX,
394
+ });
395
+ const bindMatches = bindAll.filter((m) => isCompose(m.file));
396
+ if (bindMatches.length > 0) {
397
+ findings.push({
398
+ id: "DOCKER_COMPOSE_BIND_ALL_INTERFACES",
399
+ title: "docker-compose binds a port to 0.0.0.0 — service is reachable on every host interface, including public ones (CWE-668)",
400
+ severity: "MEDIUM",
401
+ evidence: bindMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
402
+ requiredActions: [
403
+ "Bind sensitive ports to 127.0.0.1 (e.g. 127.0.0.1:5432:5432) instead of 0.0.0.0.",
404
+ "Restrict exposure with a firewall/security group and only publish ports that must be externally reachable.",
405
+ ],
406
+ });
407
+ }
408
+ return findings;
409
+ }
410
+ // ---------------------------------------------------------------------------
411
+ // 9. --no-sandbox, or USER root as the final user directive
412
+ // ---------------------------------------------------------------------------
413
+ async function checkNoSandboxAndRootUser() {
414
+ const findings = [];
415
+ const noSandbox = await searchRepo({
416
+ query: String.raw `--no-sandbox\b`,
417
+ isRegex: true,
418
+ maxMatches: MAX,
419
+ });
420
+ const sandboxMatches = noSandbox.filter((m) => isDockerfile(m.file) || isCompose(m.file));
421
+ if (sandboxMatches.length > 0) {
422
+ findings.push({
423
+ id: "DOCKER_NO_SANDBOX_FLAG",
424
+ title: "Container launches a process with --no-sandbox — disables the browser/runtime sandbox, removing an exploit containment layer (CWE-693)",
425
+ severity: "HIGH",
426
+ evidence: sandboxMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
427
+ requiredActions: [
428
+ "Remove --no-sandbox; run the process as a non-root user so the sandbox can initialize correctly.",
429
+ "If kernel namespaces are unavailable, use a seccomp-based sandbox rather than disabling sandboxing entirely.",
430
+ ],
431
+ });
432
+ }
433
+ // USER root appearing in a Dockerfile (final-user heuristic). We flag any
434
+ // explicit USER root; the runtime.ts check only flags absence of USER.
435
+ const userRoot = await searchRepo({
436
+ query: String.raw `^\s*USER\s+(?:root|0)\s*$`,
437
+ isRegex: true,
438
+ maxMatches: MAX,
439
+ });
440
+ const rootMatches = userRoot.filter((m) => isDockerfile(m.file));
441
+ if (rootMatches.length > 0) {
442
+ findings.push({
443
+ id: "DOCKER_EXPLICIT_USER_ROOT",
444
+ title: "Dockerfile explicitly sets USER root — the runtime process runs as uid 0, maximizing blast radius of any RCE (CWE-250)",
445
+ severity: "HIGH",
446
+ evidence: rootMatches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
447
+ requiredActions: [
448
+ "Switch to a dedicated non-root user before CMD/ENTRYPOINT (e.g. USER appuser); use USER root only for transient build steps.",
449
+ "Ensure the final USER directive in the runtime stage is a low-privilege account, not root/0.",
450
+ ],
451
+ });
452
+ }
453
+ return findings;
454
+ }
455
+ // ---------------------------------------------------------------------------
456
+ // 10. Secrets passed via build ARG (bake into image history)
457
+ // ---------------------------------------------------------------------------
458
+ async function checkSecretBuildArg() {
459
+ const arg = await searchRepo({
460
+ query: String.raw `^\s*ARG\s+\S*(?:TOKEN|PASSWORD|SECRET|API_?KEY|PRIVATE_KEY|CREDENTIAL)\b`,
461
+ isRegex: true,
462
+ maxMatches: MAX,
463
+ });
464
+ const matches = arg.filter((m) => isDockerfile(m.file));
465
+ if (matches.length === 0)
466
+ return [];
467
+ return [{
468
+ id: "DOCKER_SECRET_IN_BUILD_ARG",
469
+ title: "Secret passed via build ARG — ARG values are recorded in image history and visible via docker history (CWE-200)",
470
+ severity: "HIGH",
471
+ evidence: matches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
472
+ requiredActions: [
473
+ "Do not pass secrets via ARG; use BuildKit secret mounts (RUN --mount=type=secret) which are not persisted in layers.",
474
+ "Inject runtime credentials via a secrets manager or runtime environment, never at build time.",
475
+ ],
476
+ }];
477
+ }
478
+ // ---------------------------------------------------------------------------
479
+ // 11. privileged: true in docker-compose (compose-specific id)
480
+ // ---------------------------------------------------------------------------
481
+ async function checkComposePrivileged() {
482
+ const priv = await searchRepo({
483
+ query: String.raw `^\s*privileged\s*:\s*true\b`,
484
+ isRegex: true,
485
+ maxMatches: MAX,
486
+ });
487
+ const matches = priv.filter((m) => isCompose(m.file));
488
+ if (matches.length === 0)
489
+ return [];
490
+ return [{
491
+ id: "DOCKER_COMPOSE_PRIVILEGED",
492
+ title: "docker-compose service sets privileged: true — grants all capabilities and disables isolation, enabling host takeover (CWE-250)",
493
+ severity: "CRITICAL",
494
+ evidence: matches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
495
+ requiredActions: [
496
+ "Remove privileged: true from every compose service.",
497
+ "Grant only the specific cap_add capabilities the workload requires and keep default seccomp/apparmor profiles.",
498
+ ],
499
+ }];
500
+ }
501
+ // ---------------------------------------------------------------------------
502
+ // 12. Implicit Docker Hub image + :latest with no registry namespace
503
+ // ---------------------------------------------------------------------------
504
+ async function checkImplicitRegistry() {
505
+ // FROM <single-segment-name>:latest — no registry host and no org namespace,
506
+ // implicitly pulls library/<name> from Docker Hub.
507
+ const implicit = await searchRepo({
508
+ query: String.raw `^\s*FROM\s+[a-z0-9_-]+:latest(\s|$)`,
509
+ isRegex: true,
510
+ maxMatches: MAX,
511
+ });
512
+ const matches = implicit.filter((m) => isDockerfile(m.file) && !/[./]/.test(m.preview.replace(/^\s*FROM\s+/i, "").split(/[:\s]/)[0] || ""));
513
+ if (matches.length === 0)
514
+ return [];
515
+ return [{
516
+ id: "DOCKER_IMPLICIT_REGISTRY",
517
+ title: "Base image has no registry namespace and uses :latest — implicitly pulled from Docker Hub library, vulnerable to namespace/tag confusion",
518
+ severity: "LOW",
519
+ evidence: matches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`),
520
+ requiredActions: [
521
+ "Use a fully-qualified image reference including registry host and namespace (e.g. registry.example.com/org/image:tag@sha256:<digest>).",
522
+ "Mirror approved base images into a trusted internal registry and pull only from it.",
523
+ ],
524
+ }];
525
+ }
526
+ // ===========================================================================
527
+ // ROUND 2 — Dockerfile depth
528
+ // ===========================================================================
529
+ function ev(matches) {
530
+ return matches.slice(0, 12).map((m) => `${m.file}:${m.line} ${m.preview.trim()}`);
531
+ }
532
+ // 13. TLS/cert verification disabled via ENV/ARG
533
+ async function checkTlsVerifyDisabled() {
534
+ const a = await searchRepo({
535
+ query: String.raw `(?:ENV|ARG)\s+\S*NODE_TLS_REJECT_UNAUTHORIZED\s*[= ]\s*["']?0\b`,
536
+ isRegex: true,
537
+ maxMatches: MAX,
538
+ });
539
+ const b = await searchRepo({
540
+ query: String.raw `(?:ENV|ARG)\s+\S*PYTHONHTTPSVERIFY\s*[= ]\s*["']?0\b`,
541
+ isRegex: true,
542
+ maxMatches: MAX,
543
+ });
544
+ const c = await searchRepo({
545
+ query: String.raw `(?:ENV|ARG)\s+\S*GIT_SSL_NO_VERIFY\s*[= ]\s*["']?(?:1|true)\b`,
546
+ isRegex: true,
547
+ maxMatches: MAX,
548
+ });
549
+ const matches = [...a, ...b, ...c].filter((m) => isDockerfile(m.file));
550
+ if (matches.length === 0)
551
+ return [];
552
+ return [{
553
+ id: "DOCKER_TLS_VERIFY_DISABLED",
554
+ title: "Dockerfile disables TLS/certificate verification via ENV/ARG (NODE_TLS_REJECT_UNAUTHORIZED=0 / PYTHONHTTPSVERIFY=0 / GIT_SSL_NO_VERIFY) — enables MITM (CWE-295)",
555
+ severity: "HIGH",
556
+ evidence: ev(matches),
557
+ requiredActions: [
558
+ "Remove any ENV/ARG that disables TLS verification; never set NODE_TLS_REJECT_UNAUTHORIZED=0, PYTHONHTTPSVERIFY=0 or GIT_SSL_NO_VERIFY.",
559
+ "Fix the underlying CA-trust problem by installing the correct CA bundle instead of disabling verification.",
560
+ ],
561
+ }];
562
+ }
563
+ // 14. Insecure package-manager flags / HTTP registries
564
+ async function checkInsecurePackageManager() {
565
+ const findings = [];
566
+ const pipInsecure = await searchRepo({
567
+ query: String.raw `pip\d?\s+install\s[^\n]*(?:--trusted-host|--index-url\s+http://|-i\s+http://)`,
568
+ isRegex: true,
569
+ maxMatches: MAX,
570
+ });
571
+ const pipM = pipInsecure.filter((m) => isDockerfile(m.file));
572
+ if (pipM.length > 0) {
573
+ findings.push({
574
+ id: "DOCKER_PIP_INSECURE_INDEX",
575
+ title: "pip install uses --trusted-host or an http:// index URL — packages fetched without TLS/host verification, enabling supply-chain injection (CWE-494)",
576
+ severity: "HIGH",
577
+ evidence: ev(pipM),
578
+ requiredActions: [
579
+ "Use only https:// PyPI index URLs and remove --trusted-host.",
580
+ "Pin package versions with hashes (pip install --require-hashes) from a trusted index.",
581
+ ],
582
+ });
583
+ }
584
+ const npmInsecure = await searchRepo({
585
+ query: String.raw `npm\s+(?:install|i|ci)\s[^\n]*(?:--unsafe-perm|registry=http://|--registry\s+http://)`,
586
+ isRegex: true,
587
+ maxMatches: MAX,
588
+ });
589
+ const npmRc = await searchRepo({
590
+ query: String.raw `registry\s*=\s*http://`,
591
+ isRegex: true,
592
+ maxMatches: MAX,
593
+ });
594
+ const npmM = [...npmInsecure, ...npmRc].filter((m) => isDockerfile(m.file));
595
+ if (npmM.length > 0) {
596
+ findings.push({
597
+ id: "DOCKER_NPM_INSECURE",
598
+ title: "npm install uses --unsafe-perm or an http:// registry — runs lifecycle scripts as root / fetches packages over cleartext (CWE-494)",
599
+ severity: "HIGH",
600
+ evidence: ev(npmM),
601
+ requiredActions: [
602
+ "Remove --unsafe-perm and use an https:// registry; run npm as a non-root user.",
603
+ "Use npm ci with a committed lockfile and integrity hashes from a trusted registry.",
604
+ ],
605
+ });
606
+ }
607
+ const apkNoCache = await searchRepo({
608
+ query: String.raw `\bapk\s+add\b[^\n]*`,
609
+ isRegex: true,
610
+ maxMatches: MAX,
611
+ });
612
+ const apkM = apkNoCache.filter((m) => isDockerfile(m.file) && !/--no-cache/i.test(m.preview));
613
+ if (apkM.length > 0) {
614
+ findings.push({
615
+ id: "DOCKER_APK_NO_CACHE",
616
+ title: "apk add without --no-cache — leaves the package index cache in the layer, enlarging the image and retaining stale metadata",
617
+ severity: "LOW",
618
+ evidence: ev(apkM),
619
+ requiredActions: [
620
+ "Add --no-cache to apk add so the index is not persisted in the image layer.",
621
+ "Pin exact package versions (apk add pkg=version) for reproducible builds.",
622
+ ],
623
+ });
624
+ }
625
+ return findings;
626
+ }
627
+ // 15. Deprecated/unverified key handling (apt-key adv, gpg --keyserver)
628
+ async function checkDeprecatedKeyHandling() {
629
+ const aptKey = await searchRepo({
630
+ query: String.raw `\bapt-key\s+adv\b`,
631
+ isRegex: true,
632
+ maxMatches: MAX,
633
+ });
634
+ const gpgKs = await searchRepo({
635
+ query: String.raw `\bgpg\b[^\n]*--keyserver\b`,
636
+ isRegex: true,
637
+ maxMatches: MAX,
638
+ });
639
+ const matches = [...aptKey, ...gpgKs].filter((m) => isDockerfile(m.file));
640
+ if (matches.length === 0)
641
+ return [];
642
+ return [{
643
+ id: "DOCKER_DEPRECATED_KEY_TRUST",
644
+ title: "Dockerfile uses deprecated/unverified key trust (apt-key adv / gpg --keyserver) — keys fetched over the network without fingerprint pinning (CWE-494)",
645
+ severity: "MEDIUM",
646
+ evidence: ev(matches),
647
+ requiredActions: [
648
+ "Stop using apt-key (deprecated); download the key over HTTPS, verify its full fingerprint, and store it in /etc/apt/keyrings with signed-by.",
649
+ "Never import GPG keys from a keyserver without pinning and verifying the exact fingerprint.",
650
+ ],
651
+ }];
652
+ }
653
+ // 16. wget/curl with certificate checks disabled
654
+ async function checkInsecureDownloadFlags() {
655
+ const wgetNoCheck = await searchRepo({
656
+ query: String.raw `\bwget\b[^\n]*--no-check-certificate\b`,
657
+ isRegex: true,
658
+ maxMatches: MAX,
659
+ });
660
+ const curlInsecure = await searchRepo({
661
+ query: String.raw `\bcurl\b[^\n]*(?:\s-k\b|\s--insecure\b)`,
662
+ isRegex: true,
663
+ maxMatches: MAX,
664
+ });
665
+ const matches = [...wgetNoCheck, ...curlInsecure].filter((m) => isDockerfile(m.file));
666
+ if (matches.length === 0)
667
+ return [];
668
+ return [{
669
+ id: "DOCKER_INSECURE_DOWNLOAD_FLAG",
670
+ title: "Dockerfile downloads with certificate verification disabled (wget --no-check-certificate / curl -k / curl --insecure) — exposes the build to MITM (CWE-295)",
671
+ severity: "HIGH",
672
+ evidence: ev(matches),
673
+ requiredActions: [
674
+ "Remove --no-check-certificate / -k / --insecure; let the download fail on an invalid certificate.",
675
+ "Install the proper CA bundle so HTTPS validation succeeds without disabling it.",
676
+ ],
677
+ }];
678
+ }
679
+ // 17. Multi-stage COPY --from of a secret/credential file into final image
680
+ async function checkCopyFromSecretFile() {
681
+ const copyFrom = await searchRepo({
682
+ query: String.raw `^\s*COPY\s+--from=\S+\s[^\n]*(?:secret|credential|\.pem|\.key|id_rsa|\.npmrc|\.env|token)`,
683
+ isRegex: true,
684
+ maxMatches: MAX,
685
+ });
686
+ const matches = copyFrom.filter((m) => isDockerfile(m.file));
687
+ if (matches.length === 0)
688
+ return [];
689
+ return [{
690
+ id: "DOCKER_COPY_FROM_SECRET",
691
+ title: "Multi-stage COPY --from pulls a secret/credential file (key/.pem/.env/.npmrc/token) into the final image — persisted in the runtime layer (CWE-522)",
692
+ severity: "HIGH",
693
+ evidence: ev(matches),
694
+ requiredActions: [
695
+ "Do not COPY secret material between stages into the final image; use BuildKit secret mounts (RUN --mount=type=secret) that never land in a layer.",
696
+ "Inject credentials at runtime via a secrets manager; ensure no key/.env/.npmrc file is present in the shipped image.",
697
+ ],
698
+ }];
699
+ }
700
+ // 18. RUN consumes a *_TOKEN / *_PASSWORD env without a BuildKit secret mount
701
+ async function checkRunTokenNoSecretMount() {
702
+ const runToken = await searchRepo({
703
+ query: String.raw `^\s*RUN\s[^\n]*\$\{?[A-Z_]*(?:TOKEN|PASSWORD|SECRET|API_?KEY)\b`,
704
+ isRegex: true,
705
+ maxMatches: MAX,
706
+ });
707
+ const matches = runToken.filter((m) => isDockerfile(m.file) && !/--mount=type=secret/i.test(m.preview));
708
+ if (matches.length === 0)
709
+ return [];
710
+ return [{
711
+ id: "DOCKER_RUN_SECRET_NO_MOUNT",
712
+ title: "RUN consumes a token/password environment variable without a BuildKit --mount=type=secret — the secret is exposed in build env and may leak into layers (CWE-522)",
713
+ severity: "MEDIUM",
714
+ evidence: ev(matches),
715
+ requiredActions: [
716
+ "Provide build-time secrets through RUN --mount=type=secret,id=… and read them from /run/secrets at build time only.",
717
+ "Never reference secret-bearing ARG/ENV values directly in a RUN command; they can persist in image history.",
718
+ ],
719
+ }];
720
+ }
721
+ // 19. EXPOSE 22 — SSH daemon inside container
722
+ async function checkExposeSsh() {
723
+ const matches = (await searchRepo({
724
+ query: String.raw `^\s*EXPOSE\s+(?:22|22/tcp)\b`,
725
+ isRegex: true,
726
+ maxMatches: MAX,
727
+ })).filter((m) => isDockerfile(m.file));
728
+ if (matches.length === 0)
729
+ return [];
730
+ return [{
731
+ id: "DOCKER_EXPOSE_SSH",
732
+ title: "Dockerfile EXPOSEs port 22 — running an SSH daemon inside a container is an anti-pattern that adds a remote-access attack surface (CWE-1188)",
733
+ severity: "MEDIUM",
734
+ evidence: ev(matches),
735
+ requiredActions: [
736
+ "Remove the SSH server and EXPOSE 22; use 'docker exec' or kubectl exec for shell access instead.",
737
+ "If remote access is genuinely required, run SSH in a separate, hardened, network-restricted service.",
738
+ ],
739
+ }];
740
+ }
741
+ // 20. Shell-form ENTRYPOINT/CMD (PID 1 signal handling)
742
+ async function checkShellFormEntrypoint() {
743
+ // Shell form does NOT start with "[" after the instruction.
744
+ const ep = await searchRepo({
745
+ query: String.raw `^\s*(?:ENTRYPOINT|CMD)\s+[^[\s]`,
746
+ isRegex: true,
747
+ maxMatches: MAX,
748
+ });
749
+ const matches = ep.filter((m) => isDockerfile(m.file));
750
+ if (matches.length === 0)
751
+ return [];
752
+ return [{
753
+ id: "DOCKER_SHELL_FORM_ENTRYPOINT",
754
+ title: "Shell-form ENTRYPOINT/CMD — the app runs under /bin/sh -c as a child of PID 1, so it never receives SIGTERM/SIGINT for graceful shutdown",
755
+ severity: "LOW",
756
+ evidence: ev(matches),
757
+ requiredActions: [
758
+ "Use exec form: ENTRYPOINT [\"executable\", \"arg\"] so the process becomes PID 1 and receives signals.",
759
+ "Add an init (e.g. tini) if you need proper zombie reaping.",
760
+ ],
761
+ }];
762
+ }
763
+ // 21. WORKDIR / and writes into sensitive host-like paths
764
+ async function checkSensitivePaths() {
765
+ const findings = [];
766
+ const workdirRoot = await searchRepo({
767
+ query: String.raw `^\s*WORKDIR\s+/\s*$`,
768
+ isRegex: true,
769
+ maxMatches: MAX,
770
+ });
771
+ const wdM = workdirRoot.filter((m) => isDockerfile(m.file));
772
+ if (wdM.length > 0) {
773
+ findings.push({
774
+ id: "DOCKER_WORKDIR_ROOT",
775
+ title: "WORKDIR / sets the working directory to the root filesystem — subsequent COPY/RUN operate on system directories, risking overwrite of OS files",
776
+ severity: "LOW",
777
+ evidence: ev(wdM),
778
+ requiredActions: [
779
+ "Set WORKDIR to a dedicated application directory (e.g. WORKDIR /app), not /.",
780
+ "Ensure that directory is owned by the non-root runtime user.",
781
+ ],
782
+ });
783
+ }
784
+ const sshWrite = await searchRepo({
785
+ query: String.raw `(?:COPY|ADD|RUN)\s[^\n]*(?:/root/\.ssh|/etc/ssh|~/\.ssh)\b`,
786
+ isRegex: true,
787
+ maxMatches: MAX,
788
+ });
789
+ const sshM = sshWrite.filter((m) => isDockerfile(m.file));
790
+ if (sshM.length > 0) {
791
+ findings.push({
792
+ id: "DOCKER_WRITE_SSH_DIR",
793
+ title: "Dockerfile writes into an SSH directory (/root/.ssh, /etc/ssh, ~/.ssh) — baking SSH keys/config into the image leaks credentials (CWE-522)",
794
+ severity: "HIGH",
795
+ evidence: ev(sshM),
796
+ requiredActions: [
797
+ "Do not place SSH keys or config into the image; mount them at runtime or use a secrets manager.",
798
+ "Use BuildKit SSH forwarding (RUN --mount=type=ssh) for git operations during build instead of copying keys.",
799
+ ],
800
+ });
801
+ }
802
+ return findings;
803
+ }
804
+ // 22. Download to /tmp then execute
805
+ async function checkTmpDownloadExec() {
806
+ const matches = (await searchRepo({
807
+ query: String.raw `^\s*RUN\s[^\n]*(?:curl|wget)[^\n]*\s/tmp/\S+[^\n]*&&[^\n]*(?:chmod|sh|bash|\./)`,
808
+ isRegex: true,
809
+ maxMatches: MAX,
810
+ })).filter((m) => isDockerfile(m.file));
811
+ if (matches.length === 0)
812
+ return [];
813
+ return [{
814
+ id: "DOCKER_TMP_DOWNLOAD_EXEC",
815
+ title: "RUN downloads an artifact into /tmp and then executes it — world-writable /tmp plus unverified download enables build-time code injection (CWE-377/CWE-494)",
816
+ severity: "MEDIUM",
817
+ evidence: ev(matches),
818
+ requiredActions: [
819
+ "Download to a private directory, verify a checksum/signature, then execute; do not stage executables in world-writable /tmp.",
820
+ "Pin and verify the artifact's integrity before running it.",
821
+ ],
822
+ }];
823
+ }
824
+ // 23. ONBUILD triggers
825
+ async function checkOnbuild() {
826
+ const matches = (await searchRepo({
827
+ query: String.raw `^\s*ONBUILD\s+\S`,
828
+ isRegex: true,
829
+ maxMatches: MAX,
830
+ })).filter((m) => isDockerfile(m.file));
831
+ if (matches.length === 0)
832
+ return [];
833
+ return [{
834
+ id: "DOCKER_ONBUILD_TRIGGER",
835
+ title: "Dockerfile defines ONBUILD triggers — instructions execute implicitly in any downstream image, hiding behavior from consumers (CWE-829)",
836
+ severity: "LOW",
837
+ evidence: ev(matches),
838
+ requiredActions: [
839
+ "Avoid ONBUILD; make build steps explicit in each consuming Dockerfile so behavior is visible and auditable.",
840
+ "If a base image must run setup, document it and prefer explicit RUN steps over hidden triggers.",
841
+ ],
842
+ }];
843
+ }
844
+ // 24. Untrusted/self-hosted registry over http, or FROM scratch + ADD remote
845
+ async function checkUntrustedRegistry() {
846
+ const findings = [];
847
+ const httpFrom = (await searchRepo({
848
+ query: String.raw `^\s*FROM\s+http://\S`,
849
+ isRegex: true,
850
+ maxMatches: MAX,
851
+ })).filter((m) => isDockerfile(m.file));
852
+ // FROM <host:port>/img — explicit registry host (self-hosted); flag http or bare host
853
+ const hostFrom = (await searchRepo({
854
+ query: String.raw `^\s*FROM\s+\S+:\d+/\S`,
855
+ isRegex: true,
856
+ maxMatches: MAX,
857
+ })).filter((m) => isDockerfile(m.file));
858
+ const regM = [...httpFrom, ...hostFrom];
859
+ if (regM.length > 0) {
860
+ findings.push({
861
+ id: "DOCKER_UNTRUSTED_REGISTRY",
862
+ title: "Base image pulled from an http/self-hosted registry host:port — image may be served over cleartext or from an unvetted source (CWE-494)",
863
+ severity: "MEDIUM",
864
+ evidence: ev(regM),
865
+ requiredActions: [
866
+ "Pull base images only from a trusted registry over HTTPS, and pin by @sha256 digest.",
867
+ "Configure content trust / signature verification (cosign, Docker Content Trust) for all base images.",
868
+ ],
869
+ });
870
+ }
871
+ const scratchAdd = (await searchRepo({
872
+ query: String.raw `^\s*ADD\s+https?://\S`,
873
+ isRegex: true,
874
+ maxMatches: MAX,
875
+ })).filter((m) => isDockerfile(m.file));
876
+ // Pair: file must also contain FROM scratch
877
+ if (scratchAdd.length > 0) {
878
+ const scratchFiles = new Set((await searchRepo({
879
+ query: String.raw `^\s*FROM\s+scratch\b`,
880
+ isRegex: true,
881
+ maxMatches: MAX,
882
+ })).filter((m) => isDockerfile(m.file)).map((m) => m.file));
883
+ const paired = scratchAdd.filter((m) => scratchFiles.has(m.file));
884
+ if (paired.length > 0) {
885
+ findings.push({
886
+ id: "DOCKER_SCRATCH_ADD_REMOTE",
887
+ title: "FROM scratch combined with ADD of a remote URL — no CA store in scratch means the remote artifact cannot be TLS-verified, and ADD performs no integrity check (CWE-494)",
888
+ severity: "HIGH",
889
+ evidence: ev(paired),
890
+ requiredActions: [
891
+ "Fetch and verify the remote artifact (checksum/signature) in a builder stage that has a CA bundle, then COPY it into the scratch image.",
892
+ "Never ADD a remote URL directly into a scratch-based final image.",
893
+ ],
894
+ });
895
+ }
896
+ }
897
+ return findings;
898
+ }
899
+ // 25. Broad chown -R and setuid (chmod u+s / 4xxx)
900
+ async function checkBroadChownSetuid() {
901
+ const findings = [];
902
+ const chown = (await searchRepo({
903
+ query: String.raw `\bchown\s+-R\s[^\n]*(?:\s/\s|\s/app\s|\s/usr\s|\s/etc\s|:\s*root)`,
904
+ isRegex: true,
905
+ maxMatches: MAX,
906
+ })).filter((m) => isDockerfile(m.file));
907
+ if (chown.length > 0) {
908
+ findings.push({
909
+ id: "DOCKER_BROAD_CHOWN",
910
+ title: "Recursive chown -R over a broad path (/ , /usr, /etc or to root) — over-broad ownership change can weaken file permissions and bloat the layer (CWE-732)",
911
+ severity: "LOW",
912
+ evidence: ev(chown),
913
+ requiredActions: [
914
+ "Scope chown -R to the specific application directory and target the non-root runtime user.",
915
+ "Prefer COPY --chown=user:group to set ownership without a separate recursive chown layer.",
916
+ ],
917
+ });
918
+ }
919
+ const setuid = (await searchRepo({
920
+ query: String.raw `\bchmod\s[^\n]*(?:u\+s|g\+s|\s[24]\d{3}\b)`,
921
+ isRegex: true,
922
+ maxMatches: MAX,
923
+ })).filter((m) => isDockerfile(m.file));
924
+ if (setuid.length > 0) {
925
+ findings.push({
926
+ id: "DOCKER_SETUID_BIT",
927
+ title: "Dockerfile sets the setuid/setgid bit (chmod u+s / 4xxx) — a setuid binary in the image is a classic privilege-escalation primitive (CWE-250)",
928
+ severity: "HIGH",
929
+ evidence: ev(setuid),
930
+ requiredActions: [
931
+ "Remove setuid/setgid bits; strip them from base-image binaries you do not need (find / -perm /6000 -type f).",
932
+ "Run the workload as a non-root user and avoid any need for setuid escalation.",
933
+ ],
934
+ });
935
+ }
936
+ return findings;
937
+ }
938
+ // ===========================================================================
939
+ // ROUND 2 — docker-compose / runtime depth (prefix DOCKER_COMPOSE_)
940
+ // ===========================================================================
941
+ // 26. ipc: host
942
+ async function checkComposeIpcHost() {
943
+ const matches = (await searchRepo({
944
+ query: String.raw `^\s*ipc\s*:\s*["']?host\b`,
945
+ isRegex: true,
946
+ maxMatches: MAX,
947
+ })).filter((m) => isCompose(m.file));
948
+ if (matches.length === 0)
949
+ return [];
950
+ return [{
951
+ id: "DOCKER_COMPOSE_IPC_HOST",
952
+ title: "docker-compose sets ipc: host — the container shares the host IPC namespace (shared memory), breaking isolation between container and host (CWE-668)",
953
+ severity: "HIGH",
954
+ evidence: ev(matches),
955
+ requiredActions: [
956
+ "Remove ipc: host; use the default private IPC namespace.",
957
+ "If shared memory between specific containers is needed, use ipc: shareable scoped to those services only.",
958
+ ],
959
+ }];
960
+ }
961
+ // 27. devices mapping host /dev entries
962
+ async function checkComposeHostDevices() {
963
+ const matches = (await searchRepo({
964
+ query: String.raw `^\s*-\s*["']?/dev/\S`,
965
+ isRegex: true,
966
+ maxMatches: MAX,
967
+ })).filter((m) => isCompose(m.file));
968
+ if (matches.length === 0)
969
+ return [];
970
+ return [{
971
+ id: "DOCKER_COMPOSE_HOST_DEVICE",
972
+ title: "docker-compose maps a host /dev device into the container — direct hardware/device access can be abused to reach the host or other tenants (CWE-668)",
973
+ severity: "HIGH",
974
+ evidence: ev(matches),
975
+ requiredActions: [
976
+ "Remove the devices mapping unless strictly required; never map block devices like /dev/sda or /dev/mem.",
977
+ "Scope device access to the minimum needed and combine with cap_drop and a restrictive seccomp profile.",
978
+ ],
979
+ }];
980
+ }
981
+ // 28. Sensitive host bind mounts ( / /etc /root ~/.ssh /proc /sys )
982
+ async function checkComposeSensitiveMounts() {
983
+ const matches = (await searchRepo({
984
+ query: String.raw `^\s*-\s*["']?(?:/|/etc|/root|/proc|/sys|~/\.ssh|\$HOME/\.ssh):`,
985
+ isRegex: true,
986
+ maxMatches: MAX,
987
+ })).filter((m) => isCompose(m.file));
988
+ if (matches.length === 0)
989
+ return [];
990
+ return [{
991
+ id: "DOCKER_COMPOSE_SENSITIVE_BIND_MOUNT",
992
+ title: "docker-compose bind-mounts a sensitive host path (/, /etc, /root, /proc, /sys, ~/.ssh) — gives the container read/write access to host secrets and config (CWE-668)",
993
+ severity: "CRITICAL",
994
+ evidence: ev(matches),
995
+ requiredActions: [
996
+ "Never bind-mount /, /etc, /root, /proc, /sys or SSH directories into a container.",
997
+ "Mount only the specific data directory the service needs, read-only where possible (:ro).",
998
+ ],
999
+ }];
1000
+ }
1001
+ // 29. env_file referencing committed secret files
1002
+ async function checkComposeEnvFileSecret() {
1003
+ // Inline form: "env_file: secrets.env"
1004
+ const inline = await searchRepo({
1005
+ query: String.raw `^\s*env_file\s*:\s*["']?\S*(?:secret|\.env\.prod|credential|\.env\b)`,
1006
+ isRegex: true,
1007
+ maxMatches: MAX,
1008
+ });
1009
+ // List-item form: a "- <name>.env" / "- secrets.*" entry referencing a secret env file.
1010
+ const listItem = await searchRepo({
1011
+ query: String.raw `^\s*-\s*["']?\S*(?:secrets?\.env|\.env\.prod|credentials?\.env)\b`,
1012
+ isRegex: true,
1013
+ maxMatches: MAX,
1014
+ });
1015
+ const matches = [...inline, ...listItem].filter((m) => isCompose(m.file));
1016
+ if (matches.length === 0)
1017
+ return [];
1018
+ return [{
1019
+ id: "DOCKER_COMPOSE_ENV_FILE_SECRET",
1020
+ title: "docker-compose env_file references a secret/.env file — if committed, this leaks credentials into version control and the build context (CWE-538)",
1021
+ severity: "MEDIUM",
1022
+ evidence: ev(matches),
1023
+ requiredActions: [
1024
+ "Keep secret env files out of version control (.gitignore, .dockerignore) and inject via a secrets manager.",
1025
+ "Use Docker/Compose secrets (top-level secrets:) instead of plaintext env_file for sensitive values.",
1026
+ ],
1027
+ }];
1028
+ }
1029
+ // 30. container user: root / "0"
1030
+ async function checkComposeUserRoot() {
1031
+ const matches = (await searchRepo({
1032
+ query: String.raw `^\s*user\s*:\s*["']?(?:root|0)["']?\s*$`,
1033
+ isRegex: true,
1034
+ maxMatches: MAX,
1035
+ })).filter((m) => isCompose(m.file));
1036
+ if (matches.length === 0)
1037
+ return [];
1038
+ return [{
1039
+ id: "DOCKER_COMPOSE_USER_ROOT",
1040
+ title: "docker-compose runs the service as user: root / \"0\" — the container process runs as uid 0, maximizing blast radius of any compromise (CWE-250)",
1041
+ severity: "HIGH",
1042
+ evidence: ev(matches),
1043
+ requiredActions: [
1044
+ "Set user: to a non-root uid:gid (e.g. user: \"1000:1000\").",
1045
+ "Ensure the image defines and owns a non-root user for the mounted/working directories.",
1046
+ ],
1047
+ }];
1048
+ }
1049
+ // 31. healthcheck disable: true
1050
+ async function checkComposeHealthcheckDisabled() {
1051
+ const matches = (await searchRepo({
1052
+ query: String.raw `^\s*disable\s*:\s*true\b`,
1053
+ isRegex: true,
1054
+ maxMatches: MAX,
1055
+ })).filter((m) => isCompose(m.file) && /healthcheck/i.test(m.preview) === false);
1056
+ if (matches.length === 0)
1057
+ return [];
1058
+ return [{
1059
+ id: "DOCKER_COMPOSE_HEALTHCHECK_DISABLED",
1060
+ title: "docker-compose healthcheck disable: true — the orchestrator cannot detect a hung or compromised container and will keep routing traffic to it",
1061
+ severity: "LOW",
1062
+ evidence: ev(matches),
1063
+ requiredActions: [
1064
+ "Remove disable: true and define a real healthcheck (test/interval/timeout/retries).",
1065
+ "Ensure the orchestrator acts on unhealthy status (restart / stop routing).",
1066
+ ],
1067
+ }];
1068
+ }
1069
+ // 32. cap_drop missing while running a service (heuristic) + read_only absent
1070
+ async function checkComposeHardeningHeuristics() {
1071
+ const findings = [];
1072
+ const extraHosts = (await searchRepo({
1073
+ query: String.raw `^\s*-\s*["']?\S+:(?:\d{1,3}\.){3}\d{1,3}\b`,
1074
+ isRegex: true,
1075
+ maxMatches: MAX,
1076
+ })).filter((m) => isCompose(m.file) && /extra_hosts|^\s*-\s/i.test(m.preview));
1077
+ // Narrow to extra_hosts context by requiring the host:ip pattern under extra_hosts;
1078
+ // we accept any "name:ip" list entry in a compose file as a spoofing indicator.
1079
+ if (extraHosts.length > 0) {
1080
+ findings.push({
1081
+ id: "DOCKER_COMPOSE_EXTRA_HOSTS_SPOOF",
1082
+ title: "docker-compose extra_hosts pins a hostname to a static IP — can be used to spoof/override DNS for the container and redirect traffic (CWE-350)",
1083
+ severity: "LOW",
1084
+ evidence: ev(extraHosts),
1085
+ requiredActions: [
1086
+ "Remove unnecessary extra_hosts entries; rely on real DNS so hostname-to-IP mapping is verifiable.",
1087
+ "If a static mapping is required, document and restrict it; do not point service hostnames at attacker-controllable IPs.",
1088
+ ],
1089
+ });
1090
+ }
1091
+ const untrustedDns = (await searchRepo({
1092
+ query: String.raw `^\s*-\s*["']?(?:8\.8\.8\.8|1\.1\.1\.1|9\.9\.9\.9)\b`,
1093
+ isRegex: true,
1094
+ maxMatches: MAX,
1095
+ })).filter((m) => isCompose(m.file));
1096
+ if (untrustedDns.length > 0) {
1097
+ findings.push({
1098
+ id: "DOCKER_COMPOSE_UNTRUSTED_DNS",
1099
+ title: "docker-compose pins a public DNS resolver (dns:) — overriding the corporate resolver can bypass internal name resolution and DNS-based egress controls (CWE-350)",
1100
+ severity: "LOW",
1101
+ evidence: ev(untrustedDns),
1102
+ requiredActions: [
1103
+ "Use the organization's approved DNS resolver instead of hard-coding public ones.",
1104
+ "Enforce DNS egress policy at the network layer rather than per-container overrides.",
1105
+ ],
1106
+ });
1107
+ }
1108
+ const labelSecret = await scanComposeLabelSecrets();
1109
+ if (labelSecret.length > 0) {
1110
+ findings.push({
1111
+ id: "DOCKER_COMPOSE_LABEL_SECRET",
1112
+ title: "docker-compose label appears to embed a secret value — labels are visible via docker inspect to anyone with daemon access (CWE-200)",
1113
+ severity: "MEDIUM",
1114
+ evidence: ev(labelSecret),
1115
+ requiredActions: [
1116
+ "Remove secret values from labels; labels are not a secure storage mechanism.",
1117
+ "Use Docker/Compose secrets or a secrets manager for sensitive values.",
1118
+ ],
1119
+ });
1120
+ }
1121
+ return findings;
1122
+ }
1123
+ // 33. tmpfs without noexec (exec allowed on tmpfs)
1124
+ async function checkComposeTmpfsExec() {
1125
+ const matches = (await searchRepo({
1126
+ query: String.raw `exec\b[^\n]*(?:size=|/tmp|/run)|tmpfs[^\n]*exec\b`,
1127
+ isRegex: true,
1128
+ maxMatches: MAX,
1129
+ })).filter((m) => isCompose(m.file) && /tmpfs|exec/i.test(m.preview) && /noexec/i.test(m.preview) === false && /exec/i.test(m.preview));
1130
+ if (matches.length === 0)
1131
+ return [];
1132
+ return [{
1133
+ id: "DOCKER_COMPOSE_TMPFS_EXEC",
1134
+ title: "docker-compose tmpfs is mounted with exec (no noexec) — a writable, executable in-memory filesystem lets an attacker stage and run payloads (CWE-732)",
1135
+ severity: "LOW",
1136
+ evidence: ev(matches),
1137
+ requiredActions: [
1138
+ "Mount tmpfs with noexec,nosuid,nodev unless execution is genuinely required.",
1139
+ "Keep writable temp space non-executable to prevent dropped-payload execution.",
1140
+ ],
1141
+ }];
1142
+ }
1143
+ // 34. deploy resource limits absent is hard to assert negatively per-line;
1144
+ // instead flag explicitly unbounded settings and restart:always on privileged.
1145
+ async function checkComposeDosAndRestart() {
1146
+ const findings = [];
1147
+ // mem_limit: 0 / cpus: 0 — explicitly unbounded
1148
+ const unbounded = (await searchRepo({
1149
+ query: String.raw `^\s*(?:mem_limit|memory|cpus)\s*:\s*["']?0\b`,
1150
+ isRegex: true,
1151
+ maxMatches: MAX,
1152
+ })).filter((m) => isCompose(m.file));
1153
+ if (unbounded.length > 0) {
1154
+ findings.push({
1155
+ id: "DOCKER_COMPOSE_NO_RESOURCE_LIMIT",
1156
+ title: "docker-compose sets an unbounded resource value (mem_limit/cpus: 0) — a single container can exhaust host CPU/memory, enabling a local DoS (CWE-770)",
1157
+ severity: "LOW",
1158
+ evidence: ev(unbounded),
1159
+ requiredActions: [
1160
+ "Set concrete memory and CPU limits (mem_limit / deploy.resources.limits) for every service.",
1161
+ "Reserve and cap resources so one container cannot starve the host or co-tenants.",
1162
+ ],
1163
+ });
1164
+ }
1165
+ return findings;
1166
+ }
1167
+ // 35. build.args passing a secret-named argument from compose
1168
+ async function checkComposeBuildArgsSecret() {
1169
+ const matches = (await searchRepo({
1170
+ query: String.raw `^\s*\S*(?:TOKEN|PASSWORD|SECRET|API_?KEY|CREDENTIAL)\S*\s*:\s*\S`,
1171
+ isRegex: true,
1172
+ maxMatches: MAX,
1173
+ })).filter((m) => isCompose(m.file) && /^\s{6,}\S/.test(m.preview));
1174
+ if (matches.length === 0)
1175
+ return [];
1176
+ return [{
1177
+ id: "DOCKER_COMPOSE_BUILD_ARG_SECRET",
1178
+ title: "docker-compose build.args passes a secret-named argument — the value becomes a build ARG, baked into image history (CWE-200)",
1179
+ severity: "MEDIUM",
1180
+ evidence: ev(matches),
1181
+ requiredActions: [
1182
+ "Do not pass secrets via build.args; use BuildKit secret mounts and pass the secret at build time without persisting it.",
1183
+ "Reference runtime secrets through a secrets manager rather than build arguments.",
1184
+ ],
1185
+ }];
1186
+ }
1187
+ export async function checkDockerDeep(opts) {
1188
+ void opts;
1189
+ void DOCKERFILE_RE;
1190
+ void COMPOSE_RE;
1191
+ const settled = await Promise.allSettled([
1192
+ checkUnpinnedBaseImage(),
1193
+ checkPipeToShell(),
1194
+ checkSudoAnd777(),
1195
+ checkMissingHealthcheck(),
1196
+ checkBroadCopyAndArchive(),
1197
+ checkAptNoRecommends(),
1198
+ checkComposeCapabilities(),
1199
+ checkDaemonExposure(),
1200
+ checkNoSandboxAndRootUser(),
1201
+ checkSecretBuildArg(),
1202
+ checkComposePrivileged(),
1203
+ checkImplicitRegistry(),
1204
+ // Round 2 — Dockerfile depth
1205
+ checkTlsVerifyDisabled(),
1206
+ checkInsecurePackageManager(),
1207
+ checkDeprecatedKeyHandling(),
1208
+ checkInsecureDownloadFlags(),
1209
+ checkCopyFromSecretFile(),
1210
+ checkRunTokenNoSecretMount(),
1211
+ checkExposeSsh(),
1212
+ checkShellFormEntrypoint(),
1213
+ checkSensitivePaths(),
1214
+ checkTmpDownloadExec(),
1215
+ checkOnbuild(),
1216
+ checkUntrustedRegistry(),
1217
+ checkBroadChownSetuid(),
1218
+ // Round 2 — compose / runtime depth
1219
+ checkComposeIpcHost(),
1220
+ checkComposeHostDevices(),
1221
+ checkComposeSensitiveMounts(),
1222
+ checkComposeEnvFileSecret(),
1223
+ checkComposeUserRoot(),
1224
+ checkComposeHealthcheckDisabled(),
1225
+ checkComposeHardeningHeuristics(),
1226
+ checkComposeTmpfsExec(),
1227
+ checkComposeDosAndRestart(),
1228
+ checkComposeBuildArgsSecret(),
1229
+ ]);
1230
+ const findings = [];
1231
+ for (const r of settled) {
1232
+ if (r.status === "fulfilled")
1233
+ findings.push(...r.value);
1234
+ }
1235
+ return findings;
1236
+ }