docker-host-audit 0.1.1__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.
ca/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """container-audit: read-only security audit for Docker hosts."""
2
+
3
+ __version__ = "0.1.1"
ca/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m ca`."""
2
+
3
+ from ca.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
ca/checks/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Check registry. Each submodule exposes `CHECKS: list[CheckFn]`; we aggregate them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ca.models import CheckFn
6
+
7
+
8
+ def get_checks() -> list[CheckFn]:
9
+ """Every registered check, across all submodules."""
10
+ from ca.checks import exposure, hardening, images, privilege
11
+
12
+ return [*privilege.CHECKS, *exposure.CHECKS, *hardening.CHECKS, *images.CHECKS]
ca/checks/exposure.py ADDED
@@ -0,0 +1,113 @@
1
+ """Exposure checks: published ports, secrets in env, unpinned images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+
8
+ from ca.models import CheckFn, Container, Finding, Severity
9
+ from ca.redact import mask
10
+
11
+ # Matches credentials embedded in a URL, e.g. postgres://user:pass@host/db.
12
+ _URL_CREDENTIAL = re.compile(r"://[^/@:\s]+:[^/@\s]+@")
13
+
14
+ _SECRET_MARKERS = (
15
+ "PASSWORD",
16
+ "PASSWD",
17
+ "SECRET",
18
+ "TOKEN",
19
+ "API_KEY",
20
+ "APIKEY",
21
+ "ACCESS_KEY",
22
+ "PRIVATE_KEY",
23
+ "CREDENTIAL",
24
+ "AUTH",
25
+ )
26
+ _PLACEHOLDERS = ("changeme", "example")
27
+ _PLACEHOLDER_PREFIXES = ("${", "/run/secrets/")
28
+
29
+
30
+ def _is_secret_name(key: str) -> bool:
31
+ upper = key.upper()
32
+ return (
33
+ upper == "KEY"
34
+ or upper.endswith("_KEY")
35
+ or any(marker in upper for marker in _SECRET_MARKERS)
36
+ )
37
+
38
+
39
+ def _is_placeholder(value: str) -> bool:
40
+ stripped = value.strip()
41
+ return (
42
+ not stripped
43
+ or stripped.lower() in _PLACEHOLDERS
44
+ or stripped.startswith(_PLACEHOLDER_PREFIXES)
45
+ )
46
+
47
+
48
+ def _is_unpinned(image: str) -> bool:
49
+ if not image or "@" in image:
50
+ return False
51
+ last_segment = image.rsplit("/", 1)[-1] # a colon before the last "/" is a registry port
52
+ _, sep, tag = last_segment.partition(":")
53
+ return not sep or tag == "latest"
54
+
55
+
56
+ def secret_in_env(containers: list[Container]) -> Iterable[Finding]:
57
+ for c in containers:
58
+ for entry in c.env:
59
+ key, sep, value = entry.partition("=")
60
+ if not sep or _is_placeholder(value):
61
+ continue
62
+ if not _is_secret_name(key) and not _URL_CREDENTIAL.search(value):
63
+ continue
64
+ yield Finding(
65
+ check_id="secret-in-env",
66
+ title="Secret stored in environment variable",
67
+ severity=Severity.HIGH,
68
+ container=c.name,
69
+ detail=(
70
+ f"Environment variable {key} looks like a secret and has a literal "
71
+ "value; it is visible via docker inspect and to anyone who can read the "
72
+ "container configuration."
73
+ ),
74
+ evidence=f"{key}={mask(value)}",
75
+ remediation="Use Docker secrets or an env file kept outside the image.",
76
+ )
77
+
78
+
79
+ def port_all_interfaces(containers: list[Container]) -> Iterable[Finding]:
80
+ for c in containers:
81
+ for p in c.ports:
82
+ if not p.all_interfaces:
83
+ continue
84
+ yield Finding(
85
+ check_id="port-all-interfaces",
86
+ title="Port published on all interfaces",
87
+ severity=Severity.MEDIUM,
88
+ container=c.name,
89
+ detail="The port is reachable from every network the host is attached to.",
90
+ evidence=f"0.0.0.0:{p.host_port} -> {p.container_port}",
91
+ remediation="Bind to 127.0.0.1 (e.g. 127.0.0.1:<port>:<port>) unless public.",
92
+ )
93
+
94
+
95
+ def unpinned_image(containers: list[Container]) -> Iterable[Finding]:
96
+ for c in containers:
97
+ if not _is_unpinned(c.image):
98
+ continue
99
+ yield Finding(
100
+ check_id="unpinned-image",
101
+ title="Image is not pinned",
102
+ severity=Severity.LOW,
103
+ container=c.name,
104
+ detail="The image has no digest and uses no tag or the mutable ':latest' tag.",
105
+ evidence=c.image,
106
+ remediation="Pin to a digest (@sha256:...) or an explicit version tag.",
107
+ )
108
+
109
+
110
+ CHECKS: list[CheckFn] = []
111
+ CHECKS.append(secret_in_env)
112
+ CHECKS.append(port_all_interfaces)
113
+ CHECKS.append(unpinned_image)
ca/checks/hardening.py ADDED
@@ -0,0 +1,100 @@
1
+ """Host-isolation hardening checks: shared namespaces, no-new-privileges, memory limits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Any
7
+
8
+ from ca.models import CheckFn, Container, Finding, Severity
9
+
10
+
11
+ def _host_config(c: Container) -> dict[str, Any]:
12
+ host_config = c.raw.get("HostConfig")
13
+ return host_config if isinstance(host_config, dict) else {}
14
+
15
+
16
+ def host_network(containers: list[Container]) -> Iterable[Finding]:
17
+ for c in containers:
18
+ if c.network_mode == "host":
19
+ yield Finding(
20
+ check_id="host-network",
21
+ title="Container shares the host network namespace",
22
+ severity=Severity.HIGH,
23
+ container=c.name,
24
+ detail="The container shares the host network namespace, bypassing network "
25
+ "isolation and port mapping.",
26
+ evidence="HostConfig.NetworkMode=host",
27
+ remediation="Use a bridge or user-defined network instead of `network_mode: host`.",
28
+ )
29
+
30
+
31
+ def host_ipc(containers: list[Container]) -> Iterable[Finding]:
32
+ for c in containers:
33
+ if _host_config(c).get("IpcMode") == "host":
34
+ yield Finding(
35
+ check_id="host-ipc",
36
+ title="Container shares the host IPC namespace",
37
+ severity=Severity.MEDIUM,
38
+ container=c.name,
39
+ detail="The container can access host shared memory and IPC objects, "
40
+ "weakening isolation.",
41
+ evidence="HostConfig.IpcMode=host",
42
+ remediation="Remove `ipc: host`; do not share the host IPC namespace.",
43
+ )
44
+
45
+
46
+ def host_uts(containers: list[Container]) -> Iterable[Finding]:
47
+ for c in containers:
48
+ if _host_config(c).get("UTSMode") == "host":
49
+ yield Finding(
50
+ check_id="host-uts",
51
+ title="Container shares the host UTS namespace",
52
+ severity=Severity.LOW,
53
+ container=c.name,
54
+ detail="The container shares the host hostname and domain name namespace.",
55
+ evidence="HostConfig.UTSMode=host",
56
+ remediation="Remove `uts: host`; do not share the host UTS namespace.",
57
+ )
58
+
59
+
60
+ def no_new_privileges_missing(containers: list[Container]) -> Iterable[Finding]:
61
+ for c in containers:
62
+ security_opt = _host_config(c).get("SecurityOpt") or []
63
+ enabled = any(
64
+ str(opt).strip().lower().replace("=", ":") == "no-new-privileges:true"
65
+ for opt in security_opt
66
+ )
67
+ if not enabled:
68
+ yield Finding(
69
+ check_id="no-new-privileges-missing",
70
+ title="no-new-privileges is not set",
71
+ severity=Severity.MEDIUM,
72
+ container=c.name,
73
+ detail="Without no-new-privileges, setuid binaries inside the container "
74
+ "can escalate privileges.",
75
+ evidence="HostConfig.SecurityOpt lacks no-new-privileges",
76
+ remediation='Add `security_opt: ["no-new-privileges:true"]`.',
77
+ )
78
+
79
+
80
+ def unlimited_memory(containers: list[Container]) -> Iterable[Finding]:
81
+ for c in containers:
82
+ if not _host_config(c).get("Memory"):
83
+ yield Finding(
84
+ check_id="unlimited-memory",
85
+ title="Container has no memory limit",
86
+ severity=Severity.LOW,
87
+ container=c.name,
88
+ detail="With no memory limit, one container can exhaust the host's RAM.",
89
+ evidence="HostConfig.Memory=0",
90
+ remediation="Set a memory limit (`mem_limit` in compose).",
91
+ )
92
+
93
+
94
+ CHECKS: list[CheckFn] = [
95
+ host_network,
96
+ host_ipc,
97
+ host_uts,
98
+ no_new_privileges_missing,
99
+ unlimited_memory,
100
+ ]
ca/checks/images.py ADDED
@@ -0,0 +1,48 @@
1
+ """Image freshness: images built long ago likely miss upstream security patches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from datetime import UTC, date, datetime
7
+
8
+ from ca.models import CheckFn, Container, Finding, Severity
9
+
10
+ MAX_IMAGE_AGE_DAYS = 180
11
+ # Reproducible builds (distroless, ko, Nix) stamp a fixed epoch date; that is not a real age.
12
+ _EARLIEST_REAL_BUILD = date(2000, 1, 1)
13
+
14
+
15
+ def _today() -> date:
16
+ return datetime.now(UTC).date()
17
+
18
+
19
+ def _build_date(created: str) -> date | None:
20
+ try:
21
+ built = date.fromisoformat(created[:10])
22
+ except ValueError:
23
+ return None
24
+ return built if built >= _EARLIEST_REAL_BUILD else None
25
+
26
+
27
+ def stale_image(containers: list[Container]) -> Iterable[Finding]:
28
+ today = _today()
29
+ for c in containers:
30
+ built = _build_date(c.image_created)
31
+ if built is None:
32
+ continue
33
+ age = (today - built).days
34
+ if age > MAX_IMAGE_AGE_DAYS:
35
+ yield Finding(
36
+ check_id="stale-image",
37
+ title="Image was built long ago",
38
+ severity=Severity.MEDIUM,
39
+ container=c.name,
40
+ detail=f"The image is {age} days old, so it likely lacks security patches "
41
+ "released since it was built.",
42
+ evidence=f"image built {built.isoformat()} ({age} days ago)",
43
+ remediation="Pull or rebuild the image on a current base and recreate the "
44
+ "container; automate updates (e.g. Renovate, Dependabot).",
45
+ )
46
+
47
+
48
+ CHECKS: list[CheckFn] = [stale_image]
ca/checks/privilege.py ADDED
@@ -0,0 +1,146 @@
1
+ """Privilege and isolation checks: root users, privileged mode, risky mounts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from ca.models import CheckFn, Container, Finding, Mount, Severity
8
+
9
+ DANGEROUS_CAPS = frozenset(
10
+ {"SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYS_MODULE", "DAC_READ_SEARCH"}
11
+ )
12
+ SENSITIVE_SOURCES = frozenset({"/", "/etc", "/root", "/var/run", "/proc", "/sys"})
13
+
14
+
15
+ def _normalize_cap(cap: str) -> str:
16
+ cap = cap.strip().upper()
17
+ return cap.removeprefix("CAP_")
18
+
19
+
20
+ def _is_docker_sock(mount: Mount) -> bool:
21
+ return mount.source.endswith("docker.sock")
22
+
23
+
24
+ def _is_sensitive_source(source: str) -> bool:
25
+ return source in SENSITIVE_SOURCES or source == "/home" or source.startswith("/home/")
26
+
27
+
28
+ def privileged(containers: list[Container]) -> Iterable[Finding]:
29
+ for c in containers:
30
+ if c.privileged:
31
+ yield Finding(
32
+ check_id="privileged",
33
+ title="Container runs in privileged mode",
34
+ severity=Severity.CRITICAL,
35
+ container=c.name,
36
+ detail="Privileged mode grants all capabilities and host device access, "
37
+ "so a compromise is effectively a host compromise.",
38
+ evidence="HostConfig.Privileged=true",
39
+ remediation="Remove `privileged: true`; add only the capabilities needed.",
40
+ )
41
+
42
+
43
+ def docker_socket_mount(containers: list[Container]) -> Iterable[Finding]:
44
+ for c in containers:
45
+ for m in c.mounts:
46
+ if _is_docker_sock(m):
47
+ yield Finding(
48
+ check_id="docker-socket-mount",
49
+ title="Docker socket mounted into container",
50
+ severity=Severity.CRITICAL,
51
+ container=c.name,
52
+ detail="Access to the Docker socket grants control of the Docker daemon, "
53
+ "and therefore of the host.",
54
+ evidence=f"{m.source} -> {m.destination} ({'rw' if m.rw else 'ro'})",
55
+ remediation="Remove the socket mount or use a restricted docker-socket-proxy.",
56
+ )
57
+
58
+
59
+ def root_user(containers: list[Container]) -> Iterable[Finding]:
60
+ for c in containers:
61
+ if c.runs_as_root:
62
+ yield Finding(
63
+ check_id="root-user",
64
+ title="Container runs as root",
65
+ severity=Severity.HIGH,
66
+ container=c.name,
67
+ detail="The main process runs as UID 0, widening the impact of an escape.",
68
+ evidence=f"Config.User={c.user!r}",
69
+ remediation="Set a non-root USER in the image or `user:` in compose.",
70
+ )
71
+
72
+
73
+ def dangerous_caps(containers: list[Container]) -> Iterable[Finding]:
74
+ for c in containers:
75
+ matched = sorted({_normalize_cap(cap) for cap in c.cap_add} & DANGEROUS_CAPS)
76
+ if matched:
77
+ yield Finding(
78
+ check_id="dangerous-caps",
79
+ title="Container adds dangerous Linux capabilities",
80
+ severity=Severity.HIGH,
81
+ container=c.name,
82
+ detail="These capabilities allow administrative or host-affecting operations "
83
+ "and weaken container isolation.",
84
+ evidence="cap_add: " + ", ".join(matched),
85
+ remediation="Remove these from `cap_add`; grant only what is required.",
86
+ )
87
+
88
+
89
+ def sensitive_host_mount(containers: list[Container]) -> Iterable[Finding]:
90
+ for c in containers:
91
+ for m in c.mounts:
92
+ if (
93
+ m.mount_type == "bind"
94
+ and m.rw
95
+ and not _is_docker_sock(m)
96
+ and _is_sensitive_source(m.source)
97
+ ):
98
+ yield Finding(
99
+ check_id="sensitive-host-mount",
100
+ title="Sensitive host path mounted read-write",
101
+ severity=Severity.HIGH,
102
+ container=c.name,
103
+ detail="A writable bind mount of a sensitive host directory lets the container "
104
+ "modify host configuration or data.",
105
+ evidence=f"{m.source} -> {m.destination} (rw)",
106
+ remediation="Mount a narrower path, and read-only (`:ro`) where possible.",
107
+ )
108
+
109
+
110
+ def host_pid(containers: list[Container]) -> Iterable[Finding]:
111
+ for c in containers:
112
+ if c.pid_mode == "host":
113
+ yield Finding(
114
+ check_id="host-pid",
115
+ title="Container shares the host PID namespace",
116
+ severity=Severity.HIGH,
117
+ container=c.name,
118
+ detail="The container can see and signal host processes, undermining isolation.",
119
+ evidence="HostConfig.PidMode=host",
120
+ remediation="Remove `pid: host` from the container configuration.",
121
+ )
122
+
123
+
124
+ def writable_rootfs(containers: list[Container]) -> Iterable[Finding]:
125
+ for c in containers:
126
+ if not c.readonly_rootfs:
127
+ yield Finding(
128
+ check_id="writable-rootfs",
129
+ title="Root filesystem is writable",
130
+ severity=Severity.LOW,
131
+ container=c.name,
132
+ detail="A writable root filesystem lets an attacker persist changes.",
133
+ evidence="HostConfig.ReadonlyRootfs=false",
134
+ remediation="Set `read_only: true` and use tmpfs or volumes for writable paths.",
135
+ )
136
+
137
+
138
+ CHECKS: list[CheckFn] = [
139
+ privileged,
140
+ docker_socket_mount,
141
+ root_user,
142
+ dangerous_caps,
143
+ sensitive_host_mount,
144
+ host_pid,
145
+ writable_rootfs,
146
+ ]
ca/cli.py ADDED
@@ -0,0 +1,114 @@
1
+ """CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import socket
8
+ import sys
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ from ca import __version__
13
+ from ca.checks import get_checks
14
+ from ca.models import Container, Finding, Report, Severity
15
+ from ca.report import render
16
+
17
+ _FAIL_LEVELS = {
18
+ "low": Severity.LOW,
19
+ "medium": Severity.MEDIUM,
20
+ "high": Severity.HIGH,
21
+ "critical": Severity.CRITICAL,
22
+ }
23
+ _MIN_LEVELS = {"info": Severity.INFO, **_FAIL_LEVELS}
24
+
25
+
26
+ def _parser() -> argparse.ArgumentParser:
27
+ p = argparse.ArgumentParser(
28
+ prog="container-audit", description="Read-only security audit of a Docker host."
29
+ )
30
+ p.add_argument("--version", action="version", version=f"container-audit {__version__}")
31
+ p.add_argument("--input", metavar="PATH", help="JSON array of `docker inspect` objects")
32
+ p.add_argument("--format", choices=["text", "json", "markdown"], default="text")
33
+ p.add_argument("--include-stopped", action="store_true", help="also audit stopped containers")
34
+ p.add_argument(
35
+ "--fail-on",
36
+ choices=["none", *_FAIL_LEVELS],
37
+ default="none",
38
+ help="exit 1 if any finding is at or above this severity",
39
+ )
40
+ p.add_argument(
41
+ "--min-severity",
42
+ choices=list(_MIN_LEVELS),
43
+ default="info",
44
+ help="hide findings below this severity from the report (--fail-on sees the same list)",
45
+ )
46
+ p.add_argument(
47
+ "--include-host",
48
+ action="store_true",
49
+ help="include the real hostname in the report (off by default so output is safe to share)",
50
+ )
51
+ return p
52
+
53
+
54
+ def _load_unchecked(args: argparse.Namespace) -> list[Container] | None:
55
+ if args.input:
56
+ from ca.collect import parse_inspect
57
+
58
+ objects = json.loads(Path(args.input).read_text(encoding="utf-8"))
59
+ if not isinstance(objects, list) or not all(isinstance(o, dict) for o in objects):
60
+ raise ValueError("input must be a JSON array of docker inspect objects")
61
+ return parse_inspect(objects)
62
+
63
+ from ca.docker_cli import collect_running, docker_available
64
+
65
+ if not docker_available():
66
+ print("error: docker is not available (is it installed and running?)", file=sys.stderr)
67
+ return None
68
+ return collect_running(include_stopped=args.include_stopped)
69
+
70
+
71
+ def _load(args: argparse.Namespace) -> list[Container] | None:
72
+ """Load containers; on an expected failure print a short error and return None."""
73
+ try:
74
+ return _load_unchecked(args)
75
+ except FileNotFoundError:
76
+ print("error: input file not found", file=sys.stderr)
77
+ except OSError as exc:
78
+ print(f"error: cannot read input ({type(exc).__name__})", file=sys.stderr)
79
+ except RecursionError:
80
+ print("error: input JSON is nested too deeply", file=sys.stderr)
81
+ except ValueError as exc:
82
+ print(f"error: invalid input: {exc}", file=sys.stderr)
83
+ except RuntimeError as exc:
84
+ print(f"error: {exc}", file=sys.stderr)
85
+ return None
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> int:
89
+ args = _parser().parse_args(argv)
90
+ containers = _load(args)
91
+ if containers is None:
92
+ return 2
93
+
94
+ findings: list[Finding] = []
95
+ for check in get_checks():
96
+ findings.extend(check(containers))
97
+ floor = _MIN_LEVELS[args.min_severity]
98
+ findings = [f for f in findings if f.severity >= floor]
99
+
100
+ report = Report(
101
+ host=socket.gethostname() if args.include_host else "(redacted)",
102
+ scanned=len(containers),
103
+ findings=findings,
104
+ timestamp=datetime.now(UTC).isoformat(),
105
+ )
106
+ print(render(report, args.format))
107
+
108
+ if args.fail_on != "none" and report.worst >= _FAIL_LEVELS[args.fail_on]:
109
+ return 1
110
+ return 0
111
+
112
+
113
+ if __name__ == "__main__":
114
+ sys.exit(main())
ca/collect.py ADDED
@@ -0,0 +1,102 @@
1
+ """Map raw `docker inspect` JSON to Container models (read-only, no I/O)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ca.models import Container, Mount, PortBinding
8
+
9
+
10
+ def _as_dict(x: Any) -> dict:
11
+ return x if isinstance(x, dict) else {}
12
+
13
+
14
+ def _as_list(x: Any) -> list:
15
+ return x if isinstance(x, list) else []
16
+
17
+
18
+ def _as_str(x: Any) -> str:
19
+ return x if isinstance(x, str) else ""
20
+
21
+
22
+ def _str_items(x: Any) -> tuple[str, ...]:
23
+ return tuple(i for i in _as_list(x) if isinstance(i, str))
24
+
25
+
26
+ def _parse_ports(ports: Any) -> tuple[PortBinding, ...]:
27
+ """Flatten NetworkSettings.Ports; a null value means the port is not published."""
28
+ result: list[PortBinding] = []
29
+ for container_port, bindings in _as_dict(ports).items():
30
+ for binding in _as_list(bindings):
31
+ if not isinstance(binding, dict):
32
+ continue
33
+ result.append(
34
+ PortBinding(
35
+ host_ip=_as_str(binding.get("HostIp")),
36
+ host_port=_as_str(binding.get("HostPort")),
37
+ container_port=_as_str(container_port),
38
+ )
39
+ )
40
+ return tuple(result)
41
+
42
+
43
+ def _parse_mounts(mounts: Any) -> tuple[Mount, ...]:
44
+ result: list[Mount] = []
45
+ for item in _as_list(mounts):
46
+ if not isinstance(item, dict):
47
+ continue
48
+ result.append(
49
+ Mount(
50
+ source=_as_str(item.get("Source")) or _as_str(item.get("Name")),
51
+ destination=_as_str(item.get("Destination")),
52
+ mode=_as_str(item.get("Mode")),
53
+ rw=bool(item.get("RW", True)),
54
+ mount_type=_as_str(item.get("Type")),
55
+ )
56
+ )
57
+ return tuple(result)
58
+
59
+
60
+ def _parse_one(obj: dict[str, Any], image_created: dict[str, str]) -> Container:
61
+ config = _as_dict(obj.get("Config"))
62
+ host_config = _as_dict(obj.get("HostConfig"))
63
+ state = _as_dict(obj.get("State"))
64
+ network = _as_dict(obj.get("NetworkSettings"))
65
+ restart = _as_dict(host_config.get("RestartPolicy"))
66
+
67
+ return Container(
68
+ id=_as_str(obj.get("Id")),
69
+ name=_as_str(obj.get("Name")).lstrip("/"),
70
+ image=_as_str(config.get("Image")),
71
+ image_id=_as_str(obj.get("Image")),
72
+ status=_as_str(state.get("Status")),
73
+ user=_as_str(config.get("User")),
74
+ privileged=host_config.get("Privileged") is True,
75
+ cap_add=_str_items(host_config.get("CapAdd")),
76
+ cap_drop=_str_items(host_config.get("CapDrop")),
77
+ pid_mode=_as_str(host_config.get("PidMode")),
78
+ network_mode=_as_str(host_config.get("NetworkMode")),
79
+ readonly_rootfs=host_config.get("ReadonlyRootfs") is True,
80
+ ports=_parse_ports(network.get("Ports")),
81
+ mounts=_parse_mounts(obj.get("Mounts")),
82
+ env=_str_items(config.get("Env")),
83
+ restart_policy=_as_str(restart.get("Name")),
84
+ image_created=image_created.get(_as_str(obj.get("Image")), ""),
85
+ raw=obj,
86
+ )
87
+
88
+
89
+ def _is_image(obj: dict[str, Any]) -> bool:
90
+ """Image inspect objects carry RootFS; container objects never do."""
91
+ return "RootFS" in obj
92
+
93
+
94
+ def parse_inspect(objects: list[dict]) -> list[Container]:
95
+ """Parse `docker inspect` JSON objects into Container models; non-dict items are skipped.
96
+
97
+ Image objects (from `docker image inspect`) may be mixed in; they are not containers but
98
+ supply each container's image build time.
99
+ """
100
+ dicts = [o for o in objects if isinstance(o, dict)]
101
+ image_created = {_as_str(o.get("Id")): _as_str(o.get("Created")) for o in dicts if _is_image(o)}
102
+ return [_parse_one(o, image_created) for o in dicts if not _is_image(o)]
ca/docker_cli.py ADDED
@@ -0,0 +1,79 @@
1
+ """Thin read-only wrapper around the Docker CLI: only `docker ps` and `docker inspect`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ from ca.collect import parse_inspect
11
+ from ca.models import Container
12
+
13
+ _INSPECT_CHUNK = 100
14
+
15
+
16
+ def _docker_path() -> str | None:
17
+ return shutil.which("docker")
18
+
19
+
20
+ def docker_available() -> bool:
21
+ return _docker_path() is not None
22
+
23
+
24
+ def _run(args: list[str], allow_failure: bool = False) -> str:
25
+ path = _docker_path()
26
+ if path is None:
27
+ raise RuntimeError("docker executable not found in PATH")
28
+ try:
29
+ proc = subprocess.run(
30
+ [path, *args],
31
+ capture_output=True,
32
+ text=True,
33
+ check=False,
34
+ timeout=30,
35
+ )
36
+ except subprocess.TimeoutExpired as exc:
37
+ raise RuntimeError("docker timed out after 30s") from exc
38
+ except OSError as exc:
39
+ raise RuntimeError(f"failed to run docker: {exc}") from exc
40
+ if proc.returncode != 0 and not allow_failure:
41
+ raise RuntimeError(
42
+ f"docker {args[0]} failed (exit {proc.returncode}): {proc.stderr.strip()}"
43
+ )
44
+ return proc.stdout
45
+
46
+
47
+ def inspect_json(
48
+ ids: list[str], kind: str = "container", best_effort: bool = False
49
+ ) -> list[dict[str, Any]]:
50
+ """Run `docker inspect --type <kind>` on the given ids and return the parsed JSON.
51
+
52
+ With `best_effort`, ids Docker cannot find are skipped: it still prints the objects it
53
+ found as JSON and exits 1, e.g. when a running container's image was deleted.
54
+ """
55
+ if not ids:
56
+ return []
57
+ result: list[dict[str, Any]] = []
58
+ for start in range(0, len(ids), _INSPECT_CHUNK):
59
+ chunk = ids[start : start + _INSPECT_CHUNK]
60
+ out = _run(["inspect", "--type", kind, "--", *chunk], allow_failure=best_effort)
61
+ try:
62
+ data = json.loads(out)
63
+ except json.JSONDecodeError as exc:
64
+ if best_effort:
65
+ continue
66
+ raise RuntimeError(f"docker inspect returned invalid JSON: {exc}") from exc
67
+ result.extend(data)
68
+ return result
69
+
70
+
71
+ def collect_running(include_stopped: bool = False) -> list[Container]:
72
+ """List containers with `docker ps -q` (`-a` if requested) and parse their inspect data."""
73
+ args = ["ps", "-q"]
74
+ if include_stopped:
75
+ args.append("-a")
76
+ ids = _run(args).split()
77
+ containers = inspect_json(ids)
78
+ image_ids = sorted({c["Image"] for c in containers if isinstance(c.get("Image"), str)})
79
+ return parse_inspect([*containers, *inspect_json(image_ids, kind="image", best_effort=True)])
ca/models.py ADDED
@@ -0,0 +1,105 @@
1
+ """Shared data types. Every module imports from here; keep it dependency-free.
2
+
3
+ The whole tool is read-only: it parses `docker inspect` output and reports
4
+ misconfigurations. It never starts, stops, or changes a container.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable, Iterable
10
+ from dataclasses import dataclass, field
11
+ from enum import IntEnum
12
+ from typing import Any
13
+
14
+
15
+ class Severity(IntEnum):
16
+ """Ordered so findings sort high-to-low with `sorted(..., reverse=True)`."""
17
+
18
+ INFO = 0
19
+ LOW = 1
20
+ MEDIUM = 2
21
+ HIGH = 3
22
+ CRITICAL = 4
23
+
24
+ @property
25
+ def label(self) -> str:
26
+ return self.name.lower()
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class PortBinding:
31
+ """A published port. `host_ip` empty means Docker's default (all interfaces)."""
32
+
33
+ host_ip: str
34
+ host_port: str
35
+ container_port: str # e.g. "5432/tcp"
36
+
37
+ @property
38
+ def all_interfaces(self) -> bool:
39
+ return self.host_ip in ("", "0.0.0.0", "::") # noqa: S104 - detecting this, not binding
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Mount:
44
+ source: str # host path (bind) or volume name
45
+ destination: str # path inside the container
46
+ mode: str # "rw" / "ro" as reported
47
+ rw: bool
48
+ mount_type: str # "bind", "volume", "tmpfs", ...
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Container:
53
+ """The essentials parsed from one `docker inspect` object, plus the raw dict."""
54
+
55
+ id: str
56
+ name: str
57
+ image: str
58
+ image_id: str
59
+ status: str # running, exited, ...
60
+ user: str # Config.User; "" or "0"/"root" means root
61
+ privileged: bool
62
+ cap_add: tuple[str, ...]
63
+ cap_drop: tuple[str, ...]
64
+ pid_mode: str
65
+ network_mode: str
66
+ readonly_rootfs: bool
67
+ ports: tuple[PortBinding, ...]
68
+ mounts: tuple[Mount, ...]
69
+ env: tuple[str, ...] = field(repr=False) # raw "KEY=VALUE"; repr hidden so secrets never leak
70
+ restart_policy: str
71
+ image_created: str = "" # image build time from `docker image inspect`; "" if unknown
72
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
73
+
74
+ @property
75
+ def runs_as_root(self) -> bool:
76
+ return self.user.strip() in ("", "0", "root", "0:0")
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Finding:
81
+ """One problem on one container. `evidence` must already be redacted."""
82
+
83
+ check_id: str # stable kebab-case id, e.g. "root-user"
84
+ title: str
85
+ severity: Severity
86
+ container: str # container name the finding is about
87
+ detail: str # human-readable explanation, safe to print publicly
88
+ evidence: str = "" # short redacted proof, e.g. "0.0.0.0:5432->5432/tcp"
89
+ remediation: str = "" # one-line fix suggestion
90
+
91
+
92
+ # A check receives every collected container and yields findings. Pure, read-only.
93
+ CheckFn = Callable[[list[Container]], Iterable[Finding]]
94
+
95
+
96
+ @dataclass
97
+ class Report:
98
+ host: str
99
+ scanned: int # number of containers inspected
100
+ findings: list[Finding]
101
+ timestamp: str # ISO 8601 UTC
102
+
103
+ @property
104
+ def worst(self) -> Severity:
105
+ return max((f.severity for f in self.findings), default=Severity.INFO)
ca/py.typed ADDED
File without changes
ca/redact.py ADDED
@@ -0,0 +1,14 @@
1
+ """Redaction helpers shared by checks so no secret value ever reaches output."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def mask(value: str) -> str:
7
+ """Mask a sensitive value without revealing any character of it.
8
+
9
+ >>> mask("supersecret")
10
+ '***'
11
+ >>> mask("")
12
+ '***'
13
+ """
14
+ return "***"
ca/report.py ADDED
@@ -0,0 +1,105 @@
1
+ """Render a Report as text / json / markdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import unicodedata
7
+
8
+ from ca.models import Finding, Report, Severity
9
+
10
+
11
+ def _sanitize(text: str) -> str:
12
+ """Drop control (Cc), format (Cf) and other C* characters so output cannot drive a terminal."""
13
+ return "".join(ch for ch in text if not unicodedata.category(ch).startswith("C"))
14
+
15
+
16
+ def _sorted(findings: list[Finding]) -> list[Finding]:
17
+ return sorted(findings, key=lambda f: (-f.severity, f.container, f.check_id))
18
+
19
+
20
+ def _as_dict(f: Finding) -> dict[str, str]:
21
+ return {
22
+ "check_id": _sanitize(f.check_id),
23
+ "title": _sanitize(f.title),
24
+ "severity": f.severity.label,
25
+ "container": _sanitize(f.container),
26
+ "detail": _sanitize(f.detail),
27
+ "evidence": _sanitize(f.evidence),
28
+ "remediation": _sanitize(f.remediation),
29
+ }
30
+
31
+
32
+ def _text(report: Report) -> str:
33
+ lines = [
34
+ "container-audit report",
35
+ f"Host: {_sanitize(report.host)}",
36
+ f"Scanned: {report.scanned} container(s)",
37
+ f"Timestamp: {_sanitize(report.timestamp)}",
38
+ ]
39
+ counts = ", ".join(
40
+ f"{sev.label}={sum(1 for f in report.findings if f.severity == sev)}"
41
+ for sev in sorted(Severity, reverse=True)
42
+ )
43
+ lines.append(f"Findings: {len(report.findings)} ({counts})")
44
+ lines.append("")
45
+ if not report.findings:
46
+ lines.append("No findings.")
47
+ for f in _sorted(report.findings):
48
+ d = _as_dict(f)
49
+ lines.append(f"[{f.severity.name}] {d['check_id']} {d['container']} - {d['detail']}")
50
+ if d["evidence"]:
51
+ lines.append(f" evidence: {d['evidence']}")
52
+ if d["remediation"]:
53
+ lines.append(f" remediation: {d['remediation']}")
54
+ return "\n".join(lines) + "\n"
55
+
56
+
57
+ def _json(report: Report) -> str:
58
+ data = {
59
+ "host": _sanitize(report.host),
60
+ "scanned": report.scanned,
61
+ "timestamp": _sanitize(report.timestamp),
62
+ "worst": report.worst.label,
63
+ "findings": [_as_dict(f) for f in _sorted(report.findings)],
64
+ }
65
+ return json.dumps(data, indent=2, sort_keys=False)
66
+
67
+
68
+ def _cell(text: str) -> str:
69
+ out = _sanitize(text.replace("\n", " ").replace("\r", " "))
70
+ out = out.replace("\\", "\\\\") # a lone backslash before "|" would otherwise cancel its escape
71
+ for ch in "|`<>[":
72
+ out = out.replace(ch, "\\" + ch)
73
+ return out
74
+
75
+
76
+ def _markdown(report: Report) -> str:
77
+ host = _sanitize(report.host).replace("`", "'") # inside a code span, escapes do not work
78
+ lines = [
79
+ "# container-audit report",
80
+ "",
81
+ f"Host `{host}`, {report.scanned} container(s) "
82
+ f"scanned at {_cell(report.timestamp)}: "
83
+ f"{len(report.findings)} finding(s), worst severity {report.worst.label}.",
84
+ "",
85
+ ]
86
+ if not report.findings:
87
+ lines.append("No findings.")
88
+ else:
89
+ lines.append("| Severity | Check | Container | Detail | Remediation |")
90
+ lines.append("| --- | --- | --- | --- | --- |")
91
+ for f in _sorted(report.findings):
92
+ cells = [f.severity.label, f.check_id, f.container, f.detail, f.remediation]
93
+ lines.append("| " + " | ".join(_cell(c) for c in cells) + " |")
94
+ return "\n".join(lines) + "\n"
95
+
96
+
97
+ _RENDERERS = {"text": _text, "json": _json, "markdown": _markdown}
98
+
99
+
100
+ def render(report: Report, fmt: str = "text") -> str:
101
+ try:
102
+ renderer = _RENDERERS[fmt]
103
+ except KeyError:
104
+ raise ValueError(f"unknown format: {fmt!r}") from None
105
+ return renderer(report)
@@ -0,0 +1,274 @@
1
+ Metadata-Version: 2.4
2
+ Name: docker-host-audit
3
+ Version: 0.1.1
4
+ Summary: Read-only security audit for Docker hosts: flags root containers, exposed ports, secrets in env, privileged mode and mounted docker sockets.
5
+ Author: Petr Hromádka
6
+ Author-email: Petr Hromádka <109349500+petrhromadka@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ <div align="center">
12
+
13
+ # 🛡️ container-audit
14
+
15
+ **Read-only security audit for Docker hosts.**
16
+
17
+ Point it at your containers and it reports the risky configuration — root users, exposed ports,
18
+ secrets in environment variables, privileged mode, dangerous capabilities, mounted Docker sockets,
19
+ shared host namespaces, outdated images and missing hardening — as a clean text, JSON or Markdown report.
20
+
21
+ [![CI](https://github.com/petrhromadka/container-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/petrhromadka/container-audit/actions/workflows/ci.yml)
22
+ ![defensive](https://img.shields.io/badge/purpose-defensive-blue)
23
+ ![read-only](https://img.shields.io/badge/access-read--only-brightgreen)
24
+ ![python](https://img.shields.io/badge/python-3.12%2B-informational)
25
+ ![license](https://img.shields.io/badge/license-MIT-lightgrey)
26
+
27
+ [Quick start](#-quick-start) · [What it checks](#-what-it-checks) · [Usage](#-usage) · [How it works](#-how-it-works) · [Safety](#-safety)
28
+
29
+ </div>
30
+
31
+ > [!NOTE]
32
+ > **Defensive and read-only.** container-audit only runs `docker ps` and `docker inspect` (or reads
33
+ > a saved copy of that JSON). It never starts, stops, or modifies a container, and it never sends
34
+ > data anywhere.
35
+
36
+ ---
37
+
38
+ ## 🚀 Quick start
39
+
40
+ No Docker needed to try it — the repo ships a sample `docker inspect` dump:
41
+
42
+ ```bash
43
+ git clone https://github.com/petrhromadka/container-audit
44
+ cd container-audit
45
+ uv run container-audit --input tests/fixtures/sample_inspect.json
46
+ ```
47
+
48
+ You'll see eleven findings on the intentionally-insecure `bad-db` container and a clean bill of health
49
+ for the hardened `good-web` one. To scan your own host instead, just run `container-audit` — it
50
+ reads live containers via `docker ps` / `docker inspect`.
51
+
52
+ ---
53
+
54
+ ## 🔍 What it checks
55
+
56
+ Severity key: 🔴 critical · 🟠 high · 🟡 medium · 🔵 low. Each finding carries a title, a plain-language
57
+ explanation, short **redacted** evidence, and a one-line remediation.
58
+
59
+ ### Privilege &amp; escape
60
+
61
+ | | Check id | What | Why it matters |
62
+ | :-: | --- | --- | --- |
63
+ | 🔴 | `docker-socket-mount` | `/var/run/docker.sock` is mounted in | Socket access is effectively root on the host: it can start any other container. |
64
+ | 🔴 | `privileged` | Container runs `--privileged` | Disables most isolation: full device access and all capabilities. |
65
+ | 🟠 | `root-user` | Runs as root (no `USER`, or `0`/`root`) | A process escape or file-permission bug has root inside the container. |
66
+ | 🟠 | `dangerous-caps` | Caps like `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE` added | Common container-escape building blocks. |
67
+ | 🟠 | `sensitive-host-mount` | Host paths (`/`, `/etc`, `/proc`, `/root`, …) bind-mounted | A compromised container can read or alter host config and credentials. |
68
+
69
+ ### Namespaces &amp; exposure
70
+
71
+ | | Check id | What | Why it matters |
72
+ | :-: | --- | --- | --- |
73
+ | 🟠 | `host-pid` | Shares the host PID namespace (`--pid=host`) | It can see and signal every process on the host. |
74
+ | 🟠 | `host-network` | Shares the host network namespace (`--network=host`) | Network isolation and port mapping are bypassed. |
75
+ | 🟡 | `port-all-interfaces` | Port published on `0.0.0.0` / `::` | Reachable from every network the host is on, not just where you intend. |
76
+ | 🟡 | `host-ipc` | Shares the host IPC namespace (`--ipc=host`) | Shared memory and semaphores cross the container boundary. |
77
+ | 🔵 | `host-uts` | Shares the host UTS namespace (`--uts=host`) | It can change the host's hostname. |
78
+
79
+ ### Secrets &amp; hardening
80
+
81
+ | | Check id | What | Why it matters |
82
+ | :-: | --- | --- | --- |
83
+ | 🟠 | `secret-in-env` | Env var that looks like a secret, or a URL with embedded credentials | Secrets in env vars leak via `docker inspect`, logs and child processes. |
84
+ | 🟡 | `no-new-privileges-missing` | `no-new-privileges:true` is not set | setuid binaries inside the container can still escalate privileges. |
85
+ | 🔵 | `writable-rootfs` | Root filesystem is writable (not `read_only`) | An attacker can persist changes and tooling. |
86
+ | 🟡 | `stale-image` | Image was built more than 180 days ago | Old images usually miss security patches released since; rebuild or pull a current one. |
87
+ | 🔵 | `unpinned-image` | Image uses `:latest`, no tag, or no digest | Pulls aren't reproducible; upstream can silently change what runs. |
88
+ | 🔵 | `unlimited-memory` | No memory limit set | A single container can exhaust host RAM and take the host down. |
89
+
90
+ ---
91
+
92
+ ## 📦 Install
93
+
94
+ Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/). The `docker` CLI is only needed to audit
95
+ a live host; auditing a saved file with `--input` does not need it.
96
+
97
+ ```bash
98
+ uv tool install . # install as a tool from a checkout
99
+ uvx --from . container-audit # or run without installing
100
+ uv run container-audit # or run inside the project environment
101
+ uv run python -m ca # module form (use this if the console script is blocked)
102
+ ```
103
+
104
+ > On Windows, Smart App Control may block the generated `container-audit.exe` launcher. Run the
105
+ > tool as a module instead: `python -m ca` (or `uv run python -m ca`).
106
+
107
+ Once published to PyPI: `pipx install docker-host-audit` (then run `container-audit`), or `uvx docker-host-audit`.
108
+
109
+ ### Run with Docker
110
+
111
+ The image ships the tool (the Docker CLI is intentionally not bundled); feed it a saved inspect dump
112
+ on stdin:
113
+
114
+ ```bash
115
+ docker build -t container-audit .
116
+ docker inspect $(docker ps -aq) $(docker inspect -f '{{.Image}}' $(docker ps -aq) | sort -u) | docker run --rm -i container-audit --input /dev/stdin --fail-on high
117
+ ```
118
+
119
+ ---
120
+
121
+ ## 💻 Usage
122
+
123
+ ```bash
124
+ container-audit # audit running containers
125
+ container-audit --include-stopped # include stopped ones too
126
+ container-audit --format json # machine-readable output
127
+ container-audit --format markdown > report.md # for an issue / PR / wiki
128
+
129
+ docker inspect $(docker ps -aq) $(docker inspect -f '{{.Image}}' $(docker ps -aq) | sort -u) > sample.json # save containers + their images
130
+ container-audit --input sample.json --fail-on high # audit the dump; non-zero exit for CI
131
+ ```
132
+
133
+ | Flag | Meaning |
134
+ | --- | --- |
135
+ | `--input FILE` | Read `docker inspect` JSON from `FILE` instead of calling Docker. |
136
+ | `--format FORMAT` | Output format: `text` (default), `json` or `markdown`. |
137
+ | `--include-stopped` | Also inspect containers that are not running. |
138
+ | `--include-host` | Include the real hostname in the report (off by default, so output is safe to share). |
139
+ | `--min-severity LEVEL` | Hide findings below `LEVEL` (`info`, `low`, `medium`, `high`, `critical`). Default `info` (show all). |
140
+ | `--fail-on LEVEL` | Exit non-zero if any shown finding is at or above `LEVEL` (`none`, `low`, `medium`, `high`, `critical`). Default `none`. |
141
+ | `--version` | Print the version and exit. |
142
+
143
+ **Exit codes:** `0` clean (nothing reached `--fail-on`) · `1` a finding met `--fail-on` · `2` could
144
+ not run (Docker unavailable, or a missing/invalid `--input` file).
145
+
146
+ Image age comes from `docker inspect --type image`. If an image was deleted while its container
147
+ still runs, or the dump has no image objects, the age is unknown and `stale-image` skips that
148
+ container. Images from reproducible builds (distroless, ko, Nix) carry a fixed 1970 date and are
149
+ skipped too.
150
+
151
+ ### Coolify and other self-hosted PaaS
152
+
153
+ It works unchanged on a [Coolify](https://coolify.io) host. Expect `docker-socket-mount` on Coolify's
154
+ own `coolify-proxy` (Traefik) and `coolify-sentinel` containers: they need the socket by design. Review
155
+ everything else, especially apps you deployed from public templates, which often publish database
156
+ ports on `0.0.0.0` and keep passwords in environment variables.
157
+
158
+ ### Use in CI
159
+
160
+ ```yaml
161
+ - name: Audit containers
162
+ run: |
163
+ docker inspect $(docker ps -aq) $(docker inspect -f '{{.Image}}' $(docker ps -aq) | sort -u) > containers.json
164
+ uvx --from git+https://github.com/petrhromadka/container-audit \
165
+ container-audit --input containers.json --fail-on high
166
+ ```
167
+
168
+ ---
169
+
170
+ ## ⚙️ How it works
171
+
172
+ ```text
173
+ docker ps / docker inspect -> parse into Container objects -> run checks -> render report
174
+ (or --input file) (pure functions) text / json / markdown
175
+ ```
176
+
177
+ 1. **Collect** — list containers with `docker ps` and read their config with `docker inspect` (or
178
+ parse a saved copy via `--input`).
179
+ 2. **Check** — each check is a pure function over the parsed containers that yields findings. Checks
180
+ never talk to Docker and never change anything.
181
+ 3. **Report** — findings are sorted by severity and rendered as text, JSON or Markdown; with
182
+ `--fail-on`, the exit code reflects the worst severity found.
183
+
184
+ <details>
185
+ <summary>📋 Example report (from the bundled sample fixture)</summary>
186
+
187
+ ```text
188
+ container-audit report
189
+ Host: (redacted)
190
+ Scanned: 2 container(s)
191
+ Timestamp: 2026-11-05T19:32:19+00:00
192
+ Findings: 11 (critical=2, high=3, medium=4, low=2, info=0)
193
+
194
+ [CRITICAL] docker-socket-mount bad-db - Access to the Docker socket grants control of the Docker daemon, and therefore of the host.
195
+ evidence: /var/run/docker.sock -> /var/run/docker.sock (rw)
196
+ remediation: Remove the socket mount or use a restricted docker-socket-proxy.
197
+ [CRITICAL] privileged bad-db - Privileged mode grants all capabilities and host device access, so a compromise is effectively a host compromise.
198
+ evidence: HostConfig.Privileged=true
199
+ remediation: Remove `privileged: true`; add only the capabilities needed.
200
+ [HIGH] dangerous-caps bad-db - These capabilities allow administrative or host-affecting operations and weaken container isolation.
201
+ evidence: cap_add: SYS_ADMIN
202
+ remediation: Remove these from `cap_add`; grant only what is required.
203
+ [HIGH] root-user bad-db - The main process runs as UID 0, widening the impact of an escape.
204
+ evidence: Config.User=''
205
+ remediation: Set a non-root USER in the image or `user:` in compose.
206
+ [HIGH] secret-in-env bad-db - Environment variable DB_PASSWORD looks like a secret and has a literal value; it is visible via docker inspect and to anyone who can read the container configuration.
207
+ evidence: DB_PASSWORD=***
208
+ remediation: Use Docker secrets or an env file kept outside the image.
209
+ [MEDIUM] host-ipc bad-db - The container can access host shared memory and IPC objects, weakening isolation.
210
+ evidence: HostConfig.IpcMode=host
211
+ remediation: Remove `ipc: host`; do not share the host IPC namespace.
212
+ [MEDIUM] no-new-privileges-missing bad-db - Without no-new-privileges, setuid binaries inside the container can escalate privileges.
213
+ evidence: HostConfig.SecurityOpt lacks no-new-privileges
214
+ remediation: Add `security_opt: ["no-new-privileges:true"]`.
215
+ [MEDIUM] port-all-interfaces bad-db - The port is reachable from every network the host is attached to.
216
+ evidence: 0.0.0.0:5432 -> 5432/tcp
217
+ remediation: Bind to 127.0.0.1 (e.g. 127.0.0.1:<port>:<port>) unless public.
218
+ [MEDIUM] stale-image bad-db - The image is 1390 days old, so it likely lacks security patches released since it was built.
219
+ evidence: image built 2023-01-15 (1390 days ago)
220
+ remediation: Pull or rebuild the image on a current base and recreate the container; automate updates (e.g. Renovate, Dependabot).
221
+ [LOW] unlimited-memory bad-db - With no memory limit, one container can exhaust the host's RAM.
222
+ evidence: HostConfig.Memory=0
223
+ remediation: Set a memory limit (`mem_limit` in compose).
224
+ [LOW] writable-rootfs bad-db - A writable root filesystem lets an attacker persist changes.
225
+ evidence: HostConfig.ReadonlyRootfs=false
226
+ remediation: Set `read_only: true` and use tmpfs or volumes for writable paths.
227
+ ```
228
+
229
+ The second container, `good-web`, is hardened (non-root, read-only rootfs, memory limit,
230
+ `no-new-privileges`, port bound to `127.0.0.1`) and produces no findings.
231
+
232
+ </details>
233
+
234
+ ---
235
+
236
+ ## 🔒 Safety
237
+
238
+ - **Read-only.** The only Docker commands used are `docker ps` and `docker inspect`. Nothing is
239
+ started, stopped, restarted, or reconfigured.
240
+ - **Secret values are never printed.** For env vars that look like secrets, only the variable
241
+ **name** is reported, with the value masked to `***` — no characters of the value appear in any
242
+ output format.
243
+ - **Hostname hidden by default.** Reports show `(redacted)` as the host unless you pass
244
+ `--include-host`.
245
+ - **Output is sanitized.** Control and terminal-escape characters from container metadata are
246
+ stripped, so a report is safe to print in a terminal or paste into Markdown.
247
+ - **No network use, no runtime dependencies.** The tool never transmits data anywhere.
248
+ - Reports can still reveal your container names, image names, ports and mount paths — review one
249
+ before sharing it publicly.
250
+
251
+ ---
252
+
253
+ ## 🧰 Development
254
+
255
+ ```bash
256
+ uv sync # create the environment
257
+ uv run ruff check . # lint
258
+ uv run ruff format --check . # formatting
259
+ uv run ty check src # type check
260
+ uv run pytest # tests
261
+ ```
262
+
263
+ Pre-commit hooks (ruff, gitleaks, whitespace fixes) live in `.pre-commit-config.yaml`; install them
264
+ with [`prek`](https://github.com/j178/prek) or `pre-commit`. CI runs the same checks plus a gitleaks
265
+ scan over the full history.
266
+
267
+ To add a check, write a function that takes `list[Container]` and yields `Finding` objects, then
268
+ register it in the `CHECKS` list of the relevant module under `src/ca/checks/`.
269
+
270
+ ---
271
+
272
+ ## 📄 License
273
+
274
+ [MIT](LICENSE) © 2026 Petr Hromádka
@@ -0,0 +1,18 @@
1
+ ca/__init__.py,sha256=kATKlJStlr1EPkIQx9aZ2Aj7xHg7AanU4RAofvoS-eg,89
2
+ ca/__main__.py,sha256=z9v9LxLFsVruHJOhaRefPb2NsJks2Uq-WP4tCYmEew8,110
3
+ ca/checks/__init__.py,sha256=xpp1mLa90ysWbp51QD-Van77m6svty6412TPlbyCfVo,400
4
+ ca/checks/exposure.py,sha256=qmlRAWGrNzmzZsKad2OImQ2sssQWbQh_fCeNHCbWc3A,3652
5
+ ca/checks/hardening.py,sha256=fKT690-yj-RKn9403rub0B7H5dvUnNUPYd1vPcSpG5A,3847
6
+ ca/checks/images.py,sha256=dbGx-BIjgq9xamNUkrjYnHm9kUiOyYunTGc5N4kYri8,1601
7
+ ca/checks/privilege.py,sha256=QCx3UVBdmYwWK1_qPVhP04j-GGpNz6v8g0_bBvgdMZE,5646
8
+ ca/cli.py,sha256=Zt9zSaY9N0VIiBtCSn_XraPNoDSMkZZXCRm2HfUVeG8,3812
9
+ ca/collect.py,sha256=ZsYMhyYF8s0UXvVYyg-_QPocY0LqhN9KJ51G7_oiRi4,3669
10
+ ca/docker_cli.py,sha256=6JvZgzfPMj_LGJtn8nSJ5DM6cgsrJe-eNGhHigjhaxw,2623
11
+ ca/models.py,sha256=h9LMfv9wJSHIAs1THM0TOhtmIEBu__bv8SjV7Sm9WU0,3051
12
+ ca/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ ca/redact.py,sha256=MS0eZy6YoryxqA8L0J8tebcc5gjlP-byfMk6t5J9qIo,308
14
+ ca/report.py,sha256=_kjFv1tl8So4gr29tU63xlrPs6Irib9TCQ5grF5OyrQ,3571
15
+ docker_host_audit-0.1.1.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
16
+ docker_host_audit-0.1.1.dist-info/entry_points.txt,sha256=Ksza9Z2cEtVLyVkUVNklyYYkE8iwOJNqUeIV8AmFnSk,81
17
+ docker_host_audit-0.1.1.dist-info/METADATA,sha256=RDrfqGZrLdi8C9xQ4oMorSsh4nZgCgGXu8Mlc6GUbho,13971
18
+ docker_host_audit-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ container-audit = ca.cli:main
3
+ docker-host-audit = ca.cli:main
4
+