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 +3 -0
- ca/__main__.py +6 -0
- ca/checks/__init__.py +12 -0
- ca/checks/exposure.py +113 -0
- ca/checks/hardening.py +100 -0
- ca/checks/images.py +48 -0
- ca/checks/privilege.py +146 -0
- ca/cli.py +114 -0
- ca/collect.py +102 -0
- ca/docker_cli.py +79 -0
- ca/models.py +105 -0
- ca/py.typed +0 -0
- ca/redact.py +14 -0
- ca/report.py +105 -0
- docker_host_audit-0.1.1.dist-info/METADATA +274 -0
- docker_host_audit-0.1.1.dist-info/RECORD +18 -0
- docker_host_audit-0.1.1.dist-info/WHEEL +4 -0
- docker_host_audit-0.1.1.dist-info/entry_points.txt +4 -0
ca/__init__.py
ADDED
ca/__main__.py
ADDED
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
|
+
[](https://github.com/petrhromadka/container-audit/actions/workflows/ci.yml)
|
|
22
|
+

|
|
23
|
+

|
|
24
|
+

|
|
25
|
+

|
|
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 & 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 & 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 & 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,,
|