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.
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/PKG-INFO +22 -2
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/README.md +21 -1
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/pyproject.toml +1 -1
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/pyproject.toml.orig +1 -1
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/__init__.py +1 -1
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/__init__.py +8 -2
- docker_host_audit-0.3.0/src/ca/checks/runtime.py +105 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/cli.py +31 -4
- docker_host_audit-0.3.0/src/ca/config.py +64 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/__main__.py +0 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/exposure.py +0 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/hardening.py +0 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/images.py +0 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/checks/privilege.py +0 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/collect.py +0 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/docker_cli.py +0 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/models.py +0 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/py.typed +0 -0
- {docker_host_audit-0.2.0 → docker_host_audit-0.3.0}/src/ca/redact.py +0 -0
- {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.
|
|
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 & 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 & 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.
|
|
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.
|
|
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"
|
|
@@ -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 [
|
|
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(
|
|
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=
|
|
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=
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|