docker-host-audit 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/PKG-INFO +22 -2
  2. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/README.md +21 -1
  3. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/pyproject.toml +1 -1
  4. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/pyproject.toml.orig +1 -1
  5. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/__init__.py +1 -1
  6. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/__init__.py +8 -2
  7. docker_host_audit-0.3.0/src/ca/checks/runtime.py +105 -0
  8. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/cli.py +31 -4
  9. docker_host_audit-0.3.0/src/ca/config.py +64 -0
  10. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/__main__.py +0 -0
  11. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/exposure.py +0 -0
  12. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/hardening.py +0 -0
  13. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/images.py +0 -0
  14. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/privilege.py +0 -0
  15. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/collect.py +0 -0
  16. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/docker_cli.py +0 -0
  17. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/models.py +0 -0
  18. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/py.typed +0 -0
  19. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/redact.py +0 -0
  20. {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-host-audit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Read-only security audit for Docker hosts: flags root containers, exposed ports, secrets in env, privileged mode and mounted docker sockets.
5
5
  Author: Petr Hromádka
6
6
  Author-email: Petr Hromádka <109349500+petrhromadka@users.noreply.github.com>
@@ -67,7 +67,8 @@ reads live containers via `docker ps` / `docker inspect`.
67
67
  ## 🔍 What it checks
68
68
 
69
69
  Severity key: 🔴 critical · 🟠 high · 🟡 medium · 🔵 low. Each finding carries a title, a plain-language
70
- explanation, short **redacted** evidence, and a one-line remediation.
70
+ explanation, short **redacted** evidence, and a one-line remediation. See
71
+ [docs/checks.md](docs/checks.md) for every check with rationale and remediation.
71
72
 
72
73
  ### Privilege &amp; escape
73
74
 
@@ -100,6 +101,17 @@ explanation, short **redacted** evidence, and a one-line remediation.
100
101
  | 🔵 | `unpinned-image` | Image uses `:latest`, no tag, or no digest | Pulls aren't reproducible; upstream can silently change what runs. |
101
102
  | 🔵 | `unlimited-memory` | No memory limit set | A single container can exhaust host RAM and take the host down. |
102
103
 
104
+ ### Runtime hardening
105
+
106
+ | | Check id | What | Why it matters |
107
+ | :-: | --- | --- | --- |
108
+ | 🟠 | `seccomp-unconfined` | `seccomp=unconfined` disables the default syscall filter | Widens the kernel attack surface available from inside the container. |
109
+ | 🟡 | `apparmor-unconfined` | `apparmor=unconfined` disables the AppArmor profile | Drops mandatory access control confinement. |
110
+ | 🔵 | `caps-not-dropped` | Capabilities are not dropped to `ALL` | The container keeps Docker's default cap set instead of least privilege. |
111
+ | 🔵 | `no-healthcheck` | No container healthcheck is configured | Orchestration can't tell when the container is unhealthy. |
112
+
113
+ See [docs/checks.md](docs/checks.md) for every check with full rationale and remediation.
114
+
103
115
  ---
104
116
 
105
117
  ## 📦 Install
@@ -297,6 +309,14 @@ register it in the `CHECKS` list of the relevant module under `src/ca/checks/`.
297
309
 
298
310
  ---
299
311
 
312
+ ## 📚 Documentation
313
+
314
+ - [docs/checks.md](docs/checks.md) — reference for every check, with severity, trigger, rationale and remediation.
315
+ - [docs/configuration.md](docs/configuration.md) — the optional config file and its settings.
316
+ - [docs/github-action.md](docs/github-action.md) — the reusable GitHub Action for CI.
317
+
318
+ ---
319
+
300
320
  ## 📄 License
301
321
 
302
322
  [MIT](LICENSE) © 2026 Petr Hromádka
@@ -45,7 +45,8 @@ reads live containers via `docker ps` / `docker inspect`.
45
45
  ## 🔍 What it checks
46
46
 
47
47
  Severity key: 🔴 critical · 🟠 high · 🟡 medium · 🔵 low. Each finding carries a title, a plain-language
48
- explanation, short **redacted** evidence, and a one-line remediation.
48
+ explanation, short **redacted** evidence, and a one-line remediation. See
49
+ [docs/checks.md](docs/checks.md) for every check with rationale and remediation.
49
50
 
50
51
  ### Privilege &amp; escape
51
52
 
@@ -78,6 +79,17 @@ explanation, short **redacted** evidence, and a one-line remediation.
78
79
  | 🔵 | `unpinned-image` | Image uses `:latest`, no tag, or no digest | Pulls aren't reproducible; upstream can silently change what runs. |
79
80
  | 🔵 | `unlimited-memory` | No memory limit set | A single container can exhaust host RAM and take the host down. |
80
81
 
82
+ ### Runtime hardening
83
+
84
+ | | Check id | What | Why it matters |
85
+ | :-: | --- | --- | --- |
86
+ | 🟠 | `seccomp-unconfined` | `seccomp=unconfined` disables the default syscall filter | Widens the kernel attack surface available from inside the container. |
87
+ | 🟡 | `apparmor-unconfined` | `apparmor=unconfined` disables the AppArmor profile | Drops mandatory access control confinement. |
88
+ | 🔵 | `caps-not-dropped` | Capabilities are not dropped to `ALL` | The container keeps Docker's default cap set instead of least privilege. |
89
+ | 🔵 | `no-healthcheck` | No container healthcheck is configured | Orchestration can't tell when the container is unhealthy. |
90
+
91
+ See [docs/checks.md](docs/checks.md) for every check with full rationale and remediation.
92
+
81
93
  ---
82
94
 
83
95
  ## 📦 Install
@@ -275,6 +287,14 @@ register it in the `CHECKS` list of the relevant module under `src/ca/checks/`.
275
287
 
276
288
  ---
277
289
 
290
+ ## 📚 Documentation
291
+
292
+ - [docs/checks.md](docs/checks.md) — reference for every check, with severity, trigger, rationale and remediation.
293
+ - [docs/configuration.md](docs/configuration.md) — the optional config file and its settings.
294
+ - [docs/github-action.md](docs/github-action.md) — the reusable GitHub Action for CI.
295
+
296
+ ---
297
+
278
298
  ## 📄 License
279
299
 
280
300
  [MIT](LICENSE) © 2026 Petr Hromádka
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-host-audit"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Read-only security audit for Docker hosts: flags root containers, exposed ports, secrets in env, privileged mode and mounted docker sockets."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-host-audit"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Read-only security audit for Docker hosts: flags root containers, exposed ports, secrets in env, privileged mode and mounted docker sockets."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """container-audit: read-only security audit for Docker hosts."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -7,6 +7,12 @@ from ca.models import CheckFn
7
7
 
8
8
  def get_checks() -> list[CheckFn]:
9
9
  """Every registered check, across all submodules."""
10
- from ca.checks import exposure, hardening, images, privilege
10
+ from ca.checks import exposure, hardening, images, privilege, runtime
11
11
 
12
- return [*privilege.CHECKS, *exposure.CHECKS, *hardening.CHECKS, *images.CHECKS]
12
+ return [
13
+ *privilege.CHECKS,
14
+ *exposure.CHECKS,
15
+ *hardening.CHECKS,
16
+ *images.CHECKS,
17
+ *runtime.CHECKS,
18
+ ]
@@ -0,0 +1,105 @@
1
+ """Runtime hardening checks: seccomp/apparmor profiles, capability dropping, healthchecks."""
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 _security_opt(c: Container) -> list[Any]:
12
+ host_config = c.raw.get("HostConfig")
13
+ if not isinstance(host_config, dict):
14
+ return []
15
+ opts = host_config.get("SecurityOpt")
16
+ return list(opts) if isinstance(opts, list) else []
17
+
18
+
19
+ def _has_unconfined(c: Container, key: str) -> bool:
20
+ target = f"{key}:unconfined"
21
+ return any(str(opt).strip().lower().replace("=", ":") == target for opt in _security_opt(c))
22
+
23
+
24
+ def _normalize_cap(cap: str) -> str:
25
+ return cap.strip().upper().removeprefix("CAP_")
26
+
27
+
28
+ def _healthcheck_test(c: Container) -> list[Any] | None:
29
+ config = c.raw.get("Config")
30
+ if not isinstance(config, dict):
31
+ return None
32
+ healthcheck = config.get("Healthcheck")
33
+ if not isinstance(healthcheck, dict):
34
+ return None
35
+ test = healthcheck.get("Test")
36
+ return list(test) if isinstance(test, list) else []
37
+
38
+
39
+ def seccomp_unconfined(containers: list[Container]) -> Iterable[Finding]:
40
+ for c in containers:
41
+ if _has_unconfined(c, "seccomp"):
42
+ yield Finding(
43
+ check_id="seccomp-unconfined",
44
+ title="Seccomp profile disabled (unconfined)",
45
+ severity=Severity.HIGH,
46
+ container=c.name,
47
+ detail="The default seccomp syscall filter is disabled, widening the kernel "
48
+ "attack surface.",
49
+ evidence="HostConfig.SecurityOpt contains seccomp=unconfined",
50
+ remediation="Remove `seccomp=unconfined`; use the default profile.",
51
+ )
52
+
53
+
54
+ def apparmor_unconfined(containers: list[Container]) -> Iterable[Finding]:
55
+ for c in containers:
56
+ if _has_unconfined(c, "apparmor"):
57
+ yield Finding(
58
+ check_id="apparmor-unconfined",
59
+ title="AppArmor profile disabled (unconfined)",
60
+ severity=Severity.MEDIUM,
61
+ container=c.name,
62
+ detail="The container runs without an AppArmor profile, removing a layer of "
63
+ "mandatory access control.",
64
+ evidence="HostConfig.SecurityOpt contains apparmor=unconfined",
65
+ remediation="Use the default `docker-default` AppArmor profile.",
66
+ )
67
+
68
+
69
+ def caps_not_dropped(containers: list[Container]) -> Iterable[Finding]:
70
+ for c in containers:
71
+ if "ALL" not in {_normalize_cap(cap) for cap in c.cap_drop}:
72
+ yield Finding(
73
+ check_id="caps-not-dropped",
74
+ title="Default capabilities are not dropped",
75
+ severity=Severity.LOW,
76
+ container=c.name,
77
+ detail="The container keeps Docker's default capability set instead of dropping "
78
+ "all and adding only what's needed.",
79
+ evidence="cap_drop does not contain ALL",
80
+ remediation="`cap_drop: [ALL]` then add back only required caps.",
81
+ )
82
+
83
+
84
+ def no_healthcheck(containers: list[Container]) -> Iterable[Finding]:
85
+ for c in containers:
86
+ test = _healthcheck_test(c)
87
+ if not test or test == ["NONE"]:
88
+ yield Finding(
89
+ check_id="no-healthcheck",
90
+ title="Container has no healthcheck",
91
+ severity=Severity.LOW,
92
+ container=c.name,
93
+ detail="No container healthcheck, so orchestration can't detect an unhealthy "
94
+ "container.",
95
+ evidence="Config.Healthcheck is missing or disabled",
96
+ remediation="Add a HEALTHCHECK to the image or compose.",
97
+ )
98
+
99
+
100
+ CHECKS: list[CheckFn] = [
101
+ seccomp_unconfined,
102
+ apparmor_unconfined,
103
+ caps_not_dropped,
104
+ no_healthcheck,
105
+ ]
@@ -8,12 +8,16 @@ import socket
8
8
  import sys
9
9
  from datetime import UTC, datetime
10
10
  from pathlib import Path
11
+ from typing import Any
11
12
 
12
13
  from ca import __version__
13
14
  from ca.checks import get_checks
15
+ from ca.config import load_config
14
16
  from ca.models import Container, Finding, Report, Severity
15
17
  from ca.report import render
16
18
 
19
+ _DEFAULTS = {"format": "text", "fail_on": "none", "min_severity": "info"}
20
+
17
21
  _FAIL_LEVELS = {
18
22
  "low": Severity.LOW,
19
23
  "medium": Severity.MEDIUM,
@@ -29,7 +33,13 @@ def _parser() -> argparse.ArgumentParser:
29
33
  )
30
34
  p.add_argument("--version", action="version", version=f"container-audit {__version__}")
31
35
  p.add_argument("--input", metavar="PATH", help="JSON array of `docker inspect` objects")
32
- p.add_argument("--format", choices=["text", "json", "markdown", "sarif"], default="text")
36
+ p.add_argument(
37
+ "--config",
38
+ metavar="PATH",
39
+ help="path to a TOML config file; otherwise container-audit.toml / "
40
+ ".container-audit.toml in the current directory is used",
41
+ )
42
+ p.add_argument("--format", choices=["text", "json", "markdown", "sarif"], default=None)
33
43
  p.add_argument("--include-stopped", action="store_true", help="also audit stopped containers")
34
44
  p.add_argument(
35
45
  "--ignore",
@@ -40,13 +50,13 @@ def _parser() -> argparse.ArgumentParser:
40
50
  p.add_argument(
41
51
  "--fail-on",
42
52
  choices=["none", *_FAIL_LEVELS],
43
- default="none",
53
+ default=None,
44
54
  help="exit 1 if any finding is at or above this severity",
45
55
  )
46
56
  p.add_argument(
47
57
  "--min-severity",
48
58
  choices=list(_MIN_LEVELS),
49
- default="info",
59
+ default=None,
50
60
  help="hide findings below this severity from the report (--fail-on sees the same list)",
51
61
  )
52
62
  p.add_argument(
@@ -91,8 +101,26 @@ def _load(args: argparse.Namespace) -> list[Container] | None:
91
101
  return None
92
102
 
93
103
 
104
+ def _apply_config(args: argparse.Namespace, config: dict[str, Any]) -> set[str]:
105
+ """Merge config into args (CLI flag > config > default) and return the ignore set."""
106
+ for key, default in _DEFAULTS.items():
107
+ if getattr(args, key) is None:
108
+ setattr(args, key, config.get(key, default))
109
+ for key in ("include_host", "include_stopped"):
110
+ if config.get(key):
111
+ setattr(args, key, True)
112
+ return set(args.ignore or []) | set(config.get("ignore", []))
113
+
114
+
94
115
  def main(argv: list[str] | None = None) -> int:
95
116
  args = _parser().parse_args(argv)
117
+ try:
118
+ config = load_config(args.config, Path.cwd())
119
+ except ValueError as exc:
120
+ print(f"error: {exc}", file=sys.stderr)
121
+ return 2
122
+ ignored = _apply_config(args, config)
123
+
96
124
  containers = _load(args)
97
125
  if containers is None:
98
126
  return 2
@@ -102,7 +130,6 @@ def main(argv: list[str] | None = None) -> int:
102
130
  findings.extend(check(containers))
103
131
  floor = _MIN_LEVELS[args.min_severity]
104
132
  findings = [f for f in findings if f.severity >= floor]
105
- ignored = set(args.ignore or [])
106
133
  findings = [f for f in findings if f.check_id not in ignored]
107
134
 
108
135
  report = Report(
@@ -0,0 +1,64 @@
1
+ """Load optional TOML configuration. Stdlib-only, read-only.
2
+
3
+ The config file sets defaults for a few CLI options. CLI flags always win
4
+ over the file; the file wins over the built-in defaults. See `cli.py` for
5
+ how the merge is applied.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ # key -> expected python type. `ignore` is a list of strings, handled separately.
15
+ _STR_KEYS = ("min_severity", "fail_on", "format")
16
+ _BOOL_KEYS = ("include_host", "include_stopped")
17
+ _RECOGNISED = ("ignore", *_STR_KEYS, *_BOOL_KEYS)
18
+
19
+ _DISCOVERY = ("container-audit.toml", ".container-audit.toml")
20
+
21
+
22
+ def _validate(data: dict[str, Any]) -> dict[str, Any]:
23
+ result: dict[str, Any] = {}
24
+ for key, value in data.items():
25
+ if key not in _RECOGNISED:
26
+ raise ValueError(f"unknown config key: {key!r}")
27
+ if key == "ignore":
28
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
29
+ raise ValueError("config key 'ignore' must be a list of strings")
30
+ elif key in _STR_KEYS:
31
+ if not isinstance(value, str):
32
+ raise ValueError(f"config key {key!r} must be a string")
33
+ elif not isinstance(value, bool): # _BOOL_KEYS
34
+ raise ValueError(f"config key {key!r} must be a boolean")
35
+ result[key] = value
36
+ return result
37
+
38
+
39
+ def load_config(path: str | None, cwd: Path) -> dict[str, Any]:
40
+ """Return recognised config keys from `path`, or auto-discovered file, or {}.
41
+
42
+ Raises ValueError on a missing explicit path, malformed TOML, an unknown
43
+ key, or a wrong value type.
44
+ """
45
+ if path is not None:
46
+ config_path: Path | None = Path(path)
47
+ else:
48
+ config_path = next((cwd / name for name in _DISCOVERY if (cwd / name).is_file()), None)
49
+ if config_path is None:
50
+ return {}
51
+
52
+ try:
53
+ raw = config_path.read_bytes()
54
+ except FileNotFoundError:
55
+ raise ValueError(f"config file not found: {config_path}") from None
56
+ except OSError as exc:
57
+ raise ValueError(f"cannot read config file ({type(exc).__name__})") from None
58
+
59
+ try:
60
+ data = tomllib.loads(raw.decode("utf-8"))
61
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
62
+ raise ValueError(f"invalid config file: {exc}") from None
63
+
64
+ return _validate(data)