container-audit 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,4 @@
1
+ """Container Audit - Lightweight container security auditor."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "HYMichellexdd"
File without changes
@@ -0,0 +1,376 @@
1
+ """Docker security configuration checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from container_audit.models import Finding, Severity, Status
8
+
9
+
10
+ # Dangerous Linux capabilities
11
+ DANGEROUS_CAPS = {
12
+ "SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYS_RAWIO", "SYS_MODULE",
13
+ "SYS_BOOT", "AUDIT_WRITE", "MKNOD", "SETFCAP", "MAC_ADMIN",
14
+ "MAC_OVERRIDE", "DAC_READ_SEARCH", "LINUX_IMMUTABLE",
15
+ }
16
+
17
+
18
+ class DockerChecks:
19
+ """Security checks for Docker containers and images."""
20
+
21
+ def check_privileged(self, info: dict[str, Any]) -> Finding:
22
+ """Check if container runs in privileged mode."""
23
+ privileged = info.get("HostConfig", {}).get("Privileged", False)
24
+ return Finding(
25
+ check_id="DOCKER-001",
26
+ title="Privileged container",
27
+ severity=Severity.CRITICAL,
28
+ status=Status.FAIL if privileged else Status.PASS,
29
+ description=(
30
+ "Container is running in privileged mode, giving it full host access."
31
+ if privileged else
32
+ "Container is not running in privileged mode."
33
+ ),
34
+ remediation="Remove --privileged flag. Use specific capabilities instead.",
35
+ evidence=f"Privileged: {privileged}",
36
+ )
37
+
38
+ def check_docker_socket(self, info: dict[str, Any]) -> Finding:
39
+ """Check if Docker socket is mounted."""
40
+ mounts = info.get("Mounts", [])
41
+ socket_mounted = any(
42
+ "/var/run/docker.sock" in str(m.get("Source", "")) or
43
+ "/var/run/docker.sock" in str(m.get("Destination", ""))
44
+ for m in mounts
45
+ )
46
+ return Finding(
47
+ check_id="DOCKER-002",
48
+ title="Docker socket mounted",
49
+ severity=Severity.CRITICAL,
50
+ status=Status.FAIL if socket_mounted else Status.PASS,
51
+ description=(
52
+ "Docker socket is mounted into the container, allowing container escape."
53
+ if socket_mounted else
54
+ "Docker socket is not mounted."
55
+ ),
56
+ remediation="Avoid mounting Docker socket. Use Docker-in-Docker or rootless Docker.",
57
+ evidence=f"Mounts: {[m.get('Destination', '') for m in mounts]}",
58
+ )
59
+
60
+ def check_user(self, info: dict[str, Any]) -> Finding:
61
+ """Check if container runs as root."""
62
+ config = info.get("Config", {})
63
+ user = config.get("User", "")
64
+ is_root = user in ("", "root", "0", "0:0")
65
+ return Finding(
66
+ check_id="DOCKER-003",
67
+ title="Running as root",
68
+ severity=Severity.MEDIUM,
69
+ status=Status.FAIL if is_root else Status.PASS,
70
+ description=(
71
+ f"Container runs as root (User: {user or 'not set'})."
72
+ if is_root else
73
+ f"Container runs as non-root user: {user}"
74
+ ),
75
+ remediation="Set USER directive in Dockerfile or --user in docker run.",
76
+ evidence=f"User: {user or '(not set)'}",
77
+ )
78
+
79
+ def check_capabilities(self, info: dict[str, Any]) -> Finding:
80
+ """Check for dangerous capabilities."""
81
+ host_config = info.get("HostConfig", {})
82
+ cap_add = host_config.get("CapAdd") or []
83
+ dangerous = [c for c in cap_add if c in DANGEROUS_CAPS]
84
+ return Finding(
85
+ check_id="DOCKER-004",
86
+ title="Dangerous capabilities added",
87
+ severity=Severity.HIGH,
88
+ status=Status.FAIL if dangerous else Status.PASS,
89
+ description=(
90
+ f"Dangerous capabilities detected: {', '.join(dangerous)}"
91
+ if dangerous else
92
+ "No dangerous capabilities detected."
93
+ ),
94
+ remediation="Remove unnecessary capabilities. Use --cap-drop ALL --cap-add <specific>.",
95
+ evidence=f"CapAdd: {cap_add}",
96
+ )
97
+
98
+ def check_ports(self, info: dict[str, Any]) -> Finding:
99
+ """Check for exposed ports on all interfaces."""
100
+ host_config = info.get("HostConfig", {})
101
+ port_bindings = host_config.get("PortBindings") or {}
102
+ exposed_all = []
103
+ for port, bindings in port_bindings.items():
104
+ if bindings:
105
+ for binding in bindings:
106
+ host_ip = binding.get("HostIp", "")
107
+ if host_ip in ("", "0.0.0.0"):
108
+ exposed_all.append(port)
109
+
110
+ return Finding(
111
+ check_id="DOCKER-005",
112
+ title="Ports exposed on all interfaces",
113
+ severity=Severity.MEDIUM,
114
+ status=Status.FAIL if exposed_all else Status.PASS,
115
+ description=(
116
+ f"Ports exposed on 0.0.0.0: {', '.join(exposed_all)}"
117
+ if exposed_all else
118
+ "No ports exposed on all interfaces."
119
+ ),
120
+ remediation="Bind to specific IP: -p 127.0.0.1:8080:8080",
121
+ evidence=f"PortBindings: {port_bindings}",
122
+ )
123
+
124
+ def check_env_secrets(self, info: dict[str, Any]) -> Finding:
125
+ """Check for secrets in environment variables."""
126
+ config = info.get("Config", {})
127
+ env_vars = config.get("Env") or []
128
+ secret_patterns = [
129
+ "PASSWORD", "SECRET", "TOKEN", "API_KEY", "PRIVATE_KEY",
130
+ "AWS_ACCESS", "AWS_SECRET", "DATABASE_URL", "STRIPE",
131
+ ]
132
+ found_secrets = []
133
+ for env in env_vars:
134
+ key = env.split("=", 1)[0] if "=" in env else ""
135
+ if any(pattern in key.upper() for pattern in secret_patterns):
136
+ found_secrets.append(key)
137
+
138
+ return Finding(
139
+ check_id="DOCKER-006",
140
+ title="Secrets in environment variables",
141
+ severity=Severity.HIGH,
142
+ status=Status.FAIL if found_secrets else Status.PASS,
143
+ description=(
144
+ f"Potential secrets found in env vars: {', '.join(found_secrets)}"
145
+ if found_secrets else
146
+ "No secrets detected in environment variables."
147
+ ),
148
+ remediation="Use Docker secrets, mounted files, or a secrets manager instead.",
149
+ evidence=f"Env keys: {[e.split('=')[0] for e in env_vars]}",
150
+ )
151
+
152
+ def check_readonly_rootfs(self, info: dict[str, Any]) -> Finding:
153
+ """Check if root filesystem is read-only."""
154
+ host_config = info.get("HostConfig", {})
155
+ readonly = host_config.get("ReadonlyRootfs", False)
156
+ return Finding(
157
+ check_id="DOCKER-007",
158
+ title="Writable root filesystem",
159
+ severity=Severity.LOW,
160
+ status=Status.FAIL if not readonly else Status.PASS,
161
+ description=(
162
+ "Root filesystem is writable."
163
+ if not readonly else
164
+ "Root filesystem is read-only."
165
+ ),
166
+ remediation="Use --read-only flag. Mount tmpfs for writable paths.",
167
+ evidence=f"ReadonlyRootfs: {readonly}",
168
+ )
169
+
170
+ def check_resources(self, info: dict[str, Any]) -> Finding:
171
+ """Check if resource limits are set."""
172
+ host_config = info.get("HostConfig", {})
173
+ memory = host_config.get("Memory", 0)
174
+ cpu_quota = host_config.get("CpuQuota", 0)
175
+ pids_limit = host_config.get("PidsLimit", -1)
176
+
177
+ missing = []
178
+ if memory == 0:
179
+ missing.append("memory")
180
+ if cpu_quota == 0:
181
+ missing.append("CPU")
182
+ if pids_limit in (0, -1, None):
183
+ missing.append("PIDs")
184
+
185
+ return Finding(
186
+ check_id="DOCKER-008",
187
+ title="Resource limits not set",
188
+ severity=Severity.MEDIUM,
189
+ status=Status.FAIL if missing else Status.PASS,
190
+ description=(
191
+ f"Missing resource limits: {', '.join(missing)}"
192
+ if missing else
193
+ "Resource limits are configured."
194
+ ),
195
+ remediation="Set --memory, --cpus, and --pids-limit flags.",
196
+ evidence=f"Memory: {memory}, CpuQuota: {cpu_quota}, PidsLimit: {pids_limit}",
197
+ )
198
+
199
+ def check_healthcheck(self, info: dict[str, Any]) -> Finding:
200
+ """Check if healthcheck is configured."""
201
+ config = info.get("Config", {})
202
+ healthcheck = config.get("Healthcheck")
203
+ has_check = bool(healthcheck and healthcheck.get("Test"))
204
+ return Finding(
205
+ check_id="DOCKER-009",
206
+ title="No healthcheck configured",
207
+ severity=Severity.LOW,
208
+ status=Status.FAIL if not has_check else Status.PASS,
209
+ description=(
210
+ "No healthcheck configured."
211
+ if not has_check else
212
+ "Healthcheck is configured."
213
+ ),
214
+ remediation="Add HEALTHCHECK instruction in Dockerfile or --health-cmd in docker run.",
215
+ evidence=f"Healthcheck: {healthcheck}",
216
+ )
217
+
218
+ def check_apparmor(self, info: dict[str, Any]) -> Finding:
219
+ """Check AppArmor profile."""
220
+ host_config = info.get("HostConfig", {})
221
+ apparmor = host_config.get("SecurityOpt") or []
222
+ has_profile = any("apparmor" in str(opt).lower() for opt in apparmor)
223
+ return Finding(
224
+ check_id="DOCKER-010",
225
+ title="AppArmor profile",
226
+ severity=Severity.LOW,
227
+ status=Status.PASS if has_profile else Status.WARN,
228
+ description=(
229
+ f"AppArmor profile applied: {apparmor}"
230
+ if has_profile else
231
+ "No custom AppArmor profile set (using default)."
232
+ ),
233
+ remediation="Consider using a custom AppArmor profile for production.",
234
+ evidence=f"SecurityOpt: {apparmor}",
235
+ )
236
+
237
+ def check_seccomp(self, info: dict[str, Any]) -> Finding:
238
+ """Check Seccomp profile."""
239
+ host_config = info.get("HostConfig", {})
240
+ security_opt = host_config.get("SecurityOpt") or []
241
+ has_seccomp = any("seccomp" in str(opt).lower() for opt in security_opt)
242
+ return Finding(
243
+ check_id="DOCKER-011",
244
+ title="Seccomp profile",
245
+ severity=Severity.LOW,
246
+ status=Status.PASS if has_seccomp else Status.WARN,
247
+ description=(
248
+ f"Seccomp profile configured."
249
+ if has_seccomp else
250
+ "No custom Seccomp profile (using default)."
251
+ ),
252
+ remediation="Consider using a custom Seccomp profile.",
253
+ evidence=f"SecurityOpt: {security_opt}",
254
+ )
255
+
256
+ def check_pid_mode(self, info: dict[str, Any]) -> Finding:
257
+ """Check if host PID namespace is shared."""
258
+ host_config = info.get("HostConfig", {})
259
+ pid_mode = host_config.get("PidMode", "")
260
+ is_host = pid_mode == "host"
261
+ return Finding(
262
+ check_id="DOCKER-012",
263
+ title="Host PID namespace",
264
+ severity=Severity.HIGH,
265
+ status=Status.FAIL if is_host else Status.PASS,
266
+ description=(
267
+ "Container shares host PID namespace."
268
+ if is_host else
269
+ "Container uses its own PID namespace."
270
+ ),
271
+ remediation="Remove --pid=host flag.",
272
+ evidence=f"PidMode: {pid_mode}",
273
+ )
274
+
275
+ def check_ipc_mode(self, info: dict[str, Any]) -> Finding:
276
+ """Check if host IPC namespace is shared."""
277
+ host_config = info.get("HostConfig", {})
278
+ ipc_mode = host_config.get("IpcMode", "")
279
+ is_host = ipc_mode == "host"
280
+ return Finding(
281
+ check_id="DOCKER-013",
282
+ title="Host IPC namespace",
283
+ severity=Severity.MEDIUM,
284
+ status=Status.FAIL if is_host else Status.PASS,
285
+ description=(
286
+ "Container shares host IPC namespace."
287
+ if is_host else
288
+ "Container uses its own IPC namespace."
289
+ ),
290
+ remediation="Remove --ipc=host flag.",
291
+ evidence=f"IpcMode: {ipc_mode}",
292
+ )
293
+
294
+ def check_network_mode(self, info: dict[str, Any]) -> Finding:
295
+ """Check if host network mode is used."""
296
+ host_config = info.get("HostConfig", {})
297
+ network_mode = host_config.get("NetworkMode", "")
298
+ is_host = network_mode == "host"
299
+ return Finding(
300
+ check_id="DOCKER-014",
301
+ title="Host network mode",
302
+ severity=Severity.HIGH,
303
+ status=Status.FAIL if is_host else Status.PASS,
304
+ description=(
305
+ "Container uses host network mode."
306
+ if is_host else
307
+ f"Container uses network mode: {network_mode}"
308
+ ),
309
+ remediation="Use bridge or overlay network instead of host mode.",
310
+ evidence=f"NetworkMode: {network_mode}",
311
+ )
312
+
313
+ def check_compose_service(self, name: str, config: dict[str, Any]) -> list[Finding]:
314
+ """Check a docker-compose service definition."""
315
+ findings = []
316
+
317
+ # Privileged
318
+ privileged = config.get("privileged", False)
319
+ findings.append(Finding(
320
+ check_id=f"COMPOSE-{name}-001",
321
+ title=f"Service '{name}': privileged mode",
322
+ severity=Severity.CRITICAL,
323
+ status=Status.FAIL if privileged else Status.PASS,
324
+ description=f"Service '{name}' runs in privileged mode." if privileged else f"Service '{name}' is not privileged.",
325
+ remediation="Remove 'privileged: true'.",
326
+ ))
327
+
328
+ # Docker socket
329
+ volumes = config.get("volumes", [])
330
+ socket_mounted = any("/var/run/docker.sock" in str(v) for v in volumes)
331
+ findings.append(Finding(
332
+ check_id=f"COMPOSE-{name}-002",
333
+ title=f"Service '{name}': Docker socket mounted",
334
+ severity=Severity.CRITICAL,
335
+ status=Status.FAIL if socket_mounted else Status.PASS,
336
+ description=f"Docker socket mounted in '{name}'." if socket_mounted else f"No Docker socket in '{name}'.",
337
+ remediation="Remove Docker socket volume mount.",
338
+ ))
339
+
340
+ # Root user
341
+ user = config.get("user", "")
342
+ is_root = user in ("", "root", "0", "0:0")
343
+ findings.append(Finding(
344
+ check_id=f"COMPOSE-{name}-003",
345
+ title=f"Service '{name}': running as root",
346
+ severity=Severity.MEDIUM,
347
+ status=Status.FAIL if is_root else Status.PASS,
348
+ description=f"Service '{name}' runs as root." if is_root else f"Service '{name}' runs as user: {user}",
349
+ remediation="Add user: '1000:1000' or similar.",
350
+ ))
351
+
352
+ # Capabilities
353
+ cap_add = config.get("cap_add", [])
354
+ dangerous = [c for c in cap_add if c in DANGEROUS_CAPS]
355
+ findings.append(Finding(
356
+ check_id=f"COMPOSE-{name}-004",
357
+ title=f"Service '{name}': dangerous capabilities",
358
+ severity=Severity.HIGH,
359
+ status=Status.FAIL if dangerous else Status.PASS,
360
+ description=f"Dangerous capabilities in '{name}': {', '.join(dangerous)}" if dangerous else f"No dangerous caps in '{name}'.",
361
+ remediation="Remove unnecessary capabilities.",
362
+ ))
363
+
364
+ # Network mode
365
+ network_mode = config.get("network_mode", "")
366
+ if network_mode == "host":
367
+ findings.append(Finding(
368
+ check_id=f"COMPOSE-{name}-005",
369
+ title=f"Service '{name}': host network mode",
370
+ severity=Severity.HIGH,
371
+ status=Status.FAIL,
372
+ description=f"Service '{name}' uses host network mode.",
373
+ remediation="Use named network instead.",
374
+ ))
375
+
376
+ return findings