compose-auditor 0.2.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.
- compose_auditor-0.2.0/LICENSE +21 -0
- compose_auditor-0.2.0/PKG-INFO +176 -0
- compose_auditor-0.2.0/README.md +148 -0
- compose_auditor-0.2.0/compose_auditor/__init__.py +3 -0
- compose_auditor-0.2.0/compose_auditor/analyzer.py +152 -0
- compose_auditor-0.2.0/compose_auditor/cli.py +194 -0
- compose_auditor-0.2.0/compose_auditor/config.py +100 -0
- compose_auditor-0.2.0/compose_auditor/rules.py +497 -0
- compose_auditor-0.2.0/compose_auditor.egg-info/PKG-INFO +176 -0
- compose_auditor-0.2.0/compose_auditor.egg-info/SOURCES.txt +17 -0
- compose_auditor-0.2.0/compose_auditor.egg-info/dependency_links.txt +1 -0
- compose_auditor-0.2.0/compose_auditor.egg-info/entry_points.txt +2 -0
- compose_auditor-0.2.0/compose_auditor.egg-info/requires.txt +7 -0
- compose_auditor-0.2.0/compose_auditor.egg-info/top_level.txt +1 -0
- compose_auditor-0.2.0/pyproject.toml +50 -0
- compose_auditor-0.2.0/setup.cfg +4 -0
- compose_auditor-0.2.0/tests/test_analyzer.py +225 -0
- compose_auditor-0.2.0/tests/test_cli.py +187 -0
- compose_auditor-0.2.0/tests/test_rules.py +349 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fred Wojo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: compose-auditor
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Docker Compose security and best-practice linter
|
|
5
|
+
Author: Fred Wojo
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: docker,compose,security,lint,devops
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: colorama>=0.4
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# compose-auditor
|
|
30
|
+
|
|
31
|
+
Docker Compose security and best-practice linter. Catches common misconfigs, security issues, and operational gaps before they reach production.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# From source (recommended for local use)
|
|
37
|
+
python3 -m venv .venv
|
|
38
|
+
source .venv/bin/activate
|
|
39
|
+
pip install -e .
|
|
40
|
+
|
|
41
|
+
# Or directly into a venv
|
|
42
|
+
pip install compose-auditor
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Basic lint — colored text output, exits 1 on CRITICAL
|
|
49
|
+
compose-auditor lint docker-compose.yml
|
|
50
|
+
|
|
51
|
+
# Lint a specific path
|
|
52
|
+
compose-auditor lint ~/Media/docker-compose.yml
|
|
53
|
+
|
|
54
|
+
# JSON output (for CI pipelines)
|
|
55
|
+
compose-auditor lint docker-compose.yml --format json
|
|
56
|
+
|
|
57
|
+
# Control exit behavior
|
|
58
|
+
compose-auditor lint docker-compose.yml --fail-on warning # exit 1 on WARNING+
|
|
59
|
+
compose-auditor lint docker-compose.yml --fail-on never # always exit 0
|
|
60
|
+
|
|
61
|
+
# No color (for logs)
|
|
62
|
+
compose-auditor lint docker-compose.yml --no-color
|
|
63
|
+
|
|
64
|
+
# Homelab profile — suppresses noisy rules irrelevant to personal stacks
|
|
65
|
+
compose-auditor lint docker-compose.yml --profile homelab
|
|
66
|
+
|
|
67
|
+
# Ignore specific rules (repeatable)
|
|
68
|
+
compose-auditor lint docker-compose.yml --ignore SEC002 --ignore VOL001
|
|
69
|
+
|
|
70
|
+
# Use a config file explicitly
|
|
71
|
+
compose-auditor lint docker-compose.yml --config .compose-auditor.yml
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Profiles
|
|
75
|
+
|
|
76
|
+
Profiles adjust severity for context. The `homelab` profile is built-in and tuned for personal self-hosted stacks.
|
|
77
|
+
|
|
78
|
+
| Rule | Default | homelab |
|
|
79
|
+
|--------|----------|----------|
|
|
80
|
+
| VOL001 | INFO | suppressed |
|
|
81
|
+
| RES002 | INFO | suppressed |
|
|
82
|
+
| OPS003 | INFO | suppressed |
|
|
83
|
+
| NET002 | WARNING | INFO |
|
|
84
|
+
| IMG001 | WARNING | INFO |
|
|
85
|
+
| RES001 | WARNING | INFO |
|
|
86
|
+
|
|
87
|
+
## Config File
|
|
88
|
+
|
|
89
|
+
Auto-discovered from `.compose-auditor.yml` in the current directory, then the home directory. Override with `--config`.
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
# .compose-auditor.yml
|
|
93
|
+
profile: homelab
|
|
94
|
+
|
|
95
|
+
ignore:
|
|
96
|
+
- OPS003 # global — suppressed for all services
|
|
97
|
+
|
|
98
|
+
rules:
|
|
99
|
+
NET002: INFO # downgrade globally
|
|
100
|
+
|
|
101
|
+
services:
|
|
102
|
+
traefik:
|
|
103
|
+
ignore:
|
|
104
|
+
- NET001 # traefik legitimately uses host networking
|
|
105
|
+
db:
|
|
106
|
+
ignore:
|
|
107
|
+
- SEC002 # postgres image sets its own user
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Supported keys:
|
|
111
|
+
- `profile` — apply a named profile (`homelab`)
|
|
112
|
+
- `ignore` — list of rule IDs to suppress globally
|
|
113
|
+
- `rules` — map of rule ID → new severity (`CRITICAL`, `WARNING`, `INFO`)
|
|
114
|
+
- `services.<name>.ignore` — per-service rule suppression
|
|
115
|
+
|
|
116
|
+
## LSIO Auto-Detection
|
|
117
|
+
|
|
118
|
+
SEC002 (running as root / no user directive) is automatically suppressed for LinuxServer.io images. These images manage their own user mapping via `PUID`/`PGID` environment variables.
|
|
119
|
+
|
|
120
|
+
Matched prefixes:
|
|
121
|
+
- `lscr.io/linuxserver/`
|
|
122
|
+
- `linuxserver/`
|
|
123
|
+
- `ghcr.io/linuxserver/`
|
|
124
|
+
|
|
125
|
+
## Rules
|
|
126
|
+
|
|
127
|
+
| Rule ID | Severity | Description |
|
|
128
|
+
|----------|----------|-------------|
|
|
129
|
+
| SEC001 | CRITICAL | Privileged container |
|
|
130
|
+
| SEC002 | CRITICAL/WARNING | Running as root or no user directive |
|
|
131
|
+
| SEC003 | CRITICAL | Docker socket mounted |
|
|
132
|
+
| SEC004 | WARNING | Bind mount to sensitive host path (/etc, /proc, /sys, etc.) |
|
|
133
|
+
| SEC005 | CRITICAL | Plain-text secrets in environment variables |
|
|
134
|
+
| NET001 | CRITICAL | Host network mode |
|
|
135
|
+
| NET002 | WARNING | Port bound to 0.0.0.0 (all interfaces) |
|
|
136
|
+
| NET003 | CRITICAL | Duplicate host port binding across services |
|
|
137
|
+
| OPS001 | INFO | No restart policy |
|
|
138
|
+
| OPS002 | INFO/WARNING | No healthcheck or healthcheck disabled |
|
|
139
|
+
| OPS003 | INFO | No logging configuration |
|
|
140
|
+
| RES001 | WARNING | No memory limit |
|
|
141
|
+
| RES002 | INFO | No CPU limit |
|
|
142
|
+
| IMG001 | WARNING | Using :latest (or untagged) image |
|
|
143
|
+
| VOL001 | INFO | Volume mounted read-write (consider :ro) |
|
|
144
|
+
| DEP001 | INFO | Service referenced in env/links without depends_on |
|
|
145
|
+
|
|
146
|
+
NET003 is protocol-aware — TCP and UDP bindings on the same port number are treated as distinct and do not trigger a false positive.
|
|
147
|
+
|
|
148
|
+
## Exit Codes
|
|
149
|
+
|
|
150
|
+
| Code | Meaning |
|
|
151
|
+
|------|---------|
|
|
152
|
+
| 0 | No issues at or above --fail-on threshold |
|
|
153
|
+
| 1 | One or more findings at or above threshold |
|
|
154
|
+
| 2 | Parse error (invalid YAML or not a compose file) |
|
|
155
|
+
|
|
156
|
+
## JSON Output Schema
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"file": "/path/to/docker-compose.yml",
|
|
161
|
+
"summary": {
|
|
162
|
+
"CRITICAL": 3,
|
|
163
|
+
"WARNING": 7,
|
|
164
|
+
"INFO": 12
|
|
165
|
+
},
|
|
166
|
+
"findings": [
|
|
167
|
+
{
|
|
168
|
+
"severity": "CRITICAL",
|
|
169
|
+
"rule_id": "SEC001",
|
|
170
|
+
"service": "web",
|
|
171
|
+
"message": "Container runs in privileged mode",
|
|
172
|
+
"detail": "..."
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
```
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# compose-auditor
|
|
2
|
+
|
|
3
|
+
Docker Compose security and best-practice linter. Catches common misconfigs, security issues, and operational gaps before they reach production.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# From source (recommended for local use)
|
|
9
|
+
python3 -m venv .venv
|
|
10
|
+
source .venv/bin/activate
|
|
11
|
+
pip install -e .
|
|
12
|
+
|
|
13
|
+
# Or directly into a venv
|
|
14
|
+
pip install compose-auditor
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Basic lint — colored text output, exits 1 on CRITICAL
|
|
21
|
+
compose-auditor lint docker-compose.yml
|
|
22
|
+
|
|
23
|
+
# Lint a specific path
|
|
24
|
+
compose-auditor lint ~/Media/docker-compose.yml
|
|
25
|
+
|
|
26
|
+
# JSON output (for CI pipelines)
|
|
27
|
+
compose-auditor lint docker-compose.yml --format json
|
|
28
|
+
|
|
29
|
+
# Control exit behavior
|
|
30
|
+
compose-auditor lint docker-compose.yml --fail-on warning # exit 1 on WARNING+
|
|
31
|
+
compose-auditor lint docker-compose.yml --fail-on never # always exit 0
|
|
32
|
+
|
|
33
|
+
# No color (for logs)
|
|
34
|
+
compose-auditor lint docker-compose.yml --no-color
|
|
35
|
+
|
|
36
|
+
# Homelab profile — suppresses noisy rules irrelevant to personal stacks
|
|
37
|
+
compose-auditor lint docker-compose.yml --profile homelab
|
|
38
|
+
|
|
39
|
+
# Ignore specific rules (repeatable)
|
|
40
|
+
compose-auditor lint docker-compose.yml --ignore SEC002 --ignore VOL001
|
|
41
|
+
|
|
42
|
+
# Use a config file explicitly
|
|
43
|
+
compose-auditor lint docker-compose.yml --config .compose-auditor.yml
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Profiles
|
|
47
|
+
|
|
48
|
+
Profiles adjust severity for context. The `homelab` profile is built-in and tuned for personal self-hosted stacks.
|
|
49
|
+
|
|
50
|
+
| Rule | Default | homelab |
|
|
51
|
+
|--------|----------|----------|
|
|
52
|
+
| VOL001 | INFO | suppressed |
|
|
53
|
+
| RES002 | INFO | suppressed |
|
|
54
|
+
| OPS003 | INFO | suppressed |
|
|
55
|
+
| NET002 | WARNING | INFO |
|
|
56
|
+
| IMG001 | WARNING | INFO |
|
|
57
|
+
| RES001 | WARNING | INFO |
|
|
58
|
+
|
|
59
|
+
## Config File
|
|
60
|
+
|
|
61
|
+
Auto-discovered from `.compose-auditor.yml` in the current directory, then the home directory. Override with `--config`.
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
# .compose-auditor.yml
|
|
65
|
+
profile: homelab
|
|
66
|
+
|
|
67
|
+
ignore:
|
|
68
|
+
- OPS003 # global — suppressed for all services
|
|
69
|
+
|
|
70
|
+
rules:
|
|
71
|
+
NET002: INFO # downgrade globally
|
|
72
|
+
|
|
73
|
+
services:
|
|
74
|
+
traefik:
|
|
75
|
+
ignore:
|
|
76
|
+
- NET001 # traefik legitimately uses host networking
|
|
77
|
+
db:
|
|
78
|
+
ignore:
|
|
79
|
+
- SEC002 # postgres image sets its own user
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Supported keys:
|
|
83
|
+
- `profile` — apply a named profile (`homelab`)
|
|
84
|
+
- `ignore` — list of rule IDs to suppress globally
|
|
85
|
+
- `rules` — map of rule ID → new severity (`CRITICAL`, `WARNING`, `INFO`)
|
|
86
|
+
- `services.<name>.ignore` — per-service rule suppression
|
|
87
|
+
|
|
88
|
+
## LSIO Auto-Detection
|
|
89
|
+
|
|
90
|
+
SEC002 (running as root / no user directive) is automatically suppressed for LinuxServer.io images. These images manage their own user mapping via `PUID`/`PGID` environment variables.
|
|
91
|
+
|
|
92
|
+
Matched prefixes:
|
|
93
|
+
- `lscr.io/linuxserver/`
|
|
94
|
+
- `linuxserver/`
|
|
95
|
+
- `ghcr.io/linuxserver/`
|
|
96
|
+
|
|
97
|
+
## Rules
|
|
98
|
+
|
|
99
|
+
| Rule ID | Severity | Description |
|
|
100
|
+
|----------|----------|-------------|
|
|
101
|
+
| SEC001 | CRITICAL | Privileged container |
|
|
102
|
+
| SEC002 | CRITICAL/WARNING | Running as root or no user directive |
|
|
103
|
+
| SEC003 | CRITICAL | Docker socket mounted |
|
|
104
|
+
| SEC004 | WARNING | Bind mount to sensitive host path (/etc, /proc, /sys, etc.) |
|
|
105
|
+
| SEC005 | CRITICAL | Plain-text secrets in environment variables |
|
|
106
|
+
| NET001 | CRITICAL | Host network mode |
|
|
107
|
+
| NET002 | WARNING | Port bound to 0.0.0.0 (all interfaces) |
|
|
108
|
+
| NET003 | CRITICAL | Duplicate host port binding across services |
|
|
109
|
+
| OPS001 | INFO | No restart policy |
|
|
110
|
+
| OPS002 | INFO/WARNING | No healthcheck or healthcheck disabled |
|
|
111
|
+
| OPS003 | INFO | No logging configuration |
|
|
112
|
+
| RES001 | WARNING | No memory limit |
|
|
113
|
+
| RES002 | INFO | No CPU limit |
|
|
114
|
+
| IMG001 | WARNING | Using :latest (or untagged) image |
|
|
115
|
+
| VOL001 | INFO | Volume mounted read-write (consider :ro) |
|
|
116
|
+
| DEP001 | INFO | Service referenced in env/links without depends_on |
|
|
117
|
+
|
|
118
|
+
NET003 is protocol-aware — TCP and UDP bindings on the same port number are treated as distinct and do not trigger a false positive.
|
|
119
|
+
|
|
120
|
+
## Exit Codes
|
|
121
|
+
|
|
122
|
+
| Code | Meaning |
|
|
123
|
+
|------|---------|
|
|
124
|
+
| 0 | No issues at or above --fail-on threshold |
|
|
125
|
+
| 1 | One or more findings at or above threshold |
|
|
126
|
+
| 2 | Parse error (invalid YAML or not a compose file) |
|
|
127
|
+
|
|
128
|
+
## JSON Output Schema
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"file": "/path/to/docker-compose.yml",
|
|
133
|
+
"summary": {
|
|
134
|
+
"CRITICAL": 3,
|
|
135
|
+
"WARNING": 7,
|
|
136
|
+
"INFO": 12
|
|
137
|
+
},
|
|
138
|
+
"findings": [
|
|
139
|
+
{
|
|
140
|
+
"severity": "CRITICAL",
|
|
141
|
+
"rule_id": "SEC001",
|
|
142
|
+
"service": "web",
|
|
143
|
+
"message": "Container runs in privileged mode",
|
|
144
|
+
"detail": "..."
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
```
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Main analysis engine — loads a compose file and runs all rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .config import AuditConfig
|
|
12
|
+
from .rules import (
|
|
13
|
+
ALL_RULES,
|
|
14
|
+
Finding,
|
|
15
|
+
check_depends_on,
|
|
16
|
+
check_ports,
|
|
17
|
+
_parse_port_entry,
|
|
18
|
+
SEVERITY_CRITICAL,
|
|
19
|
+
SEVERITY_WARNING,
|
|
20
|
+
SEVERITY_INFO,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_compose(path: Path) -> dict[str, Any]:
|
|
25
|
+
with path.open("r") as fh:
|
|
26
|
+
data = yaml.safe_load(fh)
|
|
27
|
+
if not isinstance(data, dict):
|
|
28
|
+
raise ValueError(f"{path} does not contain a valid YAML mapping.")
|
|
29
|
+
return data
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def analyze(path: Path, config: Optional[AuditConfig] = None) -> list[Finding]:
|
|
33
|
+
if config is None:
|
|
34
|
+
config = AuditConfig()
|
|
35
|
+
|
|
36
|
+
data = _load_compose(path)
|
|
37
|
+
services: dict[str, Any] = data.get("services", {}) or {}
|
|
38
|
+
|
|
39
|
+
if not services:
|
|
40
|
+
return [
|
|
41
|
+
Finding(
|
|
42
|
+
severity=SEVERITY_WARNING,
|
|
43
|
+
rule_id="PARSE001",
|
|
44
|
+
service="(file)",
|
|
45
|
+
message="No services found in compose file",
|
|
46
|
+
detail="",
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
findings: list[Finding] = []
|
|
51
|
+
|
|
52
|
+
# Collect all port bindings across services for duplicate detection
|
|
53
|
+
global_ports: dict[str, str] = {}
|
|
54
|
+
|
|
55
|
+
for service_name, svc_config in services.items():
|
|
56
|
+
if svc_config is None:
|
|
57
|
+
svc_config = {}
|
|
58
|
+
|
|
59
|
+
for rule_fn in ALL_RULES:
|
|
60
|
+
if rule_fn is check_ports:
|
|
61
|
+
svc_findings = _check_ports_global(
|
|
62
|
+
service_name, svc_config, global_ports
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
svc_findings = rule_fn(service_name, svc_config)
|
|
66
|
+
|
|
67
|
+
findings.extend(svc_findings)
|
|
68
|
+
|
|
69
|
+
findings.extend(check_depends_on(service_name, svc_config, services))
|
|
70
|
+
|
|
71
|
+
# Apply config: filter ignored rules and adjust severities
|
|
72
|
+
result = []
|
|
73
|
+
for f in findings:
|
|
74
|
+
if config.is_ignored(f.rule_id, f.service):
|
|
75
|
+
continue
|
|
76
|
+
f.severity = config.effective_severity(f.rule_id, f.severity)
|
|
77
|
+
result.append(f)
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _check_ports_global(
|
|
83
|
+
service_name: str,
|
|
84
|
+
config: dict[str, Any],
|
|
85
|
+
global_ports: dict[str, str],
|
|
86
|
+
) -> list[Finding]:
|
|
87
|
+
findings: list[Finding] = []
|
|
88
|
+
ports = config.get("ports", []) or []
|
|
89
|
+
|
|
90
|
+
for port_entry in ports:
|
|
91
|
+
raw = str(port_entry).strip()
|
|
92
|
+
bind_ip, host_port, container_port, protocol = _parse_port_entry(raw)
|
|
93
|
+
|
|
94
|
+
# 0.0.0.0 binding warning
|
|
95
|
+
if bind_ip in ("0.0.0.0", ""):
|
|
96
|
+
findings.append(
|
|
97
|
+
Finding(
|
|
98
|
+
severity=SEVERITY_WARNING,
|
|
99
|
+
rule_id="NET002",
|
|
100
|
+
service=service_name,
|
|
101
|
+
message=f"Port {host_port} bound to all interfaces (0.0.0.0)",
|
|
102
|
+
detail=f"Use '127.0.0.1:{host_port}:{container_port}' to restrict to localhost unless external access is intentional.",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Cross-service duplicate port detection.
|
|
107
|
+
# Include protocol — TCP and UDP on the same port is valid.
|
|
108
|
+
key = f"{protocol}:{bind_ip}:{host_port}"
|
|
109
|
+
if key in global_ports:
|
|
110
|
+
findings.append(
|
|
111
|
+
Finding(
|
|
112
|
+
severity=SEVERITY_CRITICAL,
|
|
113
|
+
rule_id="NET003",
|
|
114
|
+
service=service_name,
|
|
115
|
+
message=f"Host port {host_port} already bound by '{global_ports[key]}'",
|
|
116
|
+
detail="Each host port can only be used by one service. Assign a different host port.",
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
global_ports[key] = service_name
|
|
121
|
+
|
|
122
|
+
return findings
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def format_findings_text(findings: list[Finding]) -> str:
|
|
126
|
+
lines = []
|
|
127
|
+
for f in findings:
|
|
128
|
+
lines.append(f" [{f.rule_id}] {f.message}")
|
|
129
|
+
if f.detail:
|
|
130
|
+
lines.append(f" {f.detail}")
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def findings_to_dict(findings: list[Finding]) -> list[dict[str, str]]:
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
"severity": f.severity,
|
|
138
|
+
"rule_id": f.rule_id,
|
|
139
|
+
"service": f.service,
|
|
140
|
+
"message": f.message,
|
|
141
|
+
"detail": f.detail,
|
|
142
|
+
}
|
|
143
|
+
for f in findings
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def summarize(findings: list[Finding]) -> dict[str, int]:
|
|
148
|
+
return {
|
|
149
|
+
SEVERITY_CRITICAL: sum(1 for f in findings if f.severity == SEVERITY_CRITICAL),
|
|
150
|
+
SEVERITY_WARNING: sum(1 for f in findings if f.severity == SEVERITY_WARNING),
|
|
151
|
+
SEVERITY_INFO: sum(1 for f in findings if f.severity == SEVERITY_INFO),
|
|
152
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""CLI entry point for compose-auditor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .analyzer import analyze, findings_to_dict, summarize
|
|
12
|
+
from .config import AuditConfig, PROFILES, load_config
|
|
13
|
+
from .rules import SEVERITY_CRITICAL, SEVERITY_WARNING, SEVERITY_INFO, Finding
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from colorama import Fore, Style, init as colorama_init
|
|
18
|
+
colorama_init(autoreset=True)
|
|
19
|
+
_HAS_COLOR = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
_HAS_COLOR = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _color(text: str, severity: str, no_color: bool) -> str:
|
|
25
|
+
if no_color or not _HAS_COLOR:
|
|
26
|
+
return text
|
|
27
|
+
if severity == SEVERITY_CRITICAL:
|
|
28
|
+
return f"{Fore.RED}{Style.BRIGHT}{text}{Style.RESET_ALL}"
|
|
29
|
+
if severity == SEVERITY_WARNING:
|
|
30
|
+
return f"{Fore.YELLOW}{Style.BRIGHT}{text}{Style.RESET_ALL}"
|
|
31
|
+
return f"{Fore.CYAN}{text}{Style.RESET_ALL}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _severity_icon(severity: str) -> str:
|
|
35
|
+
return {
|
|
36
|
+
SEVERITY_CRITICAL: "✖",
|
|
37
|
+
SEVERITY_WARNING: "⚠",
|
|
38
|
+
SEVERITY_INFO: "ℹ",
|
|
39
|
+
}.get(severity, "?")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_text(
|
|
43
|
+
findings: list[Finding],
|
|
44
|
+
path: Path,
|
|
45
|
+
no_color: bool,
|
|
46
|
+
profile: str = "production",
|
|
47
|
+
) -> None:
|
|
48
|
+
click.echo(f"\nAuditing: {path}")
|
|
49
|
+
if profile != "production":
|
|
50
|
+
click.echo(f"Profile: {profile}")
|
|
51
|
+
click.echo()
|
|
52
|
+
|
|
53
|
+
if not findings:
|
|
54
|
+
msg = "No issues found."
|
|
55
|
+
click.echo(_color(msg, SEVERITY_INFO, no_color))
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
grouped: dict[str, list[Finding]] = {}
|
|
59
|
+
for f in findings:
|
|
60
|
+
grouped.setdefault(f.service, []).append(f)
|
|
61
|
+
|
|
62
|
+
for service, svc_findings in grouped.items():
|
|
63
|
+
label = f" service: {service}"
|
|
64
|
+
click.echo(_color(label, SEVERITY_WARNING, no_color) if not no_color else label)
|
|
65
|
+
for f in svc_findings:
|
|
66
|
+
icon = _severity_icon(f.severity)
|
|
67
|
+
prefix = f" {icon} [{f.severity:<8}] [{f.rule_id}]"
|
|
68
|
+
line = f"{prefix} {f.message}"
|
|
69
|
+
click.echo(_color(line, f.severity, no_color))
|
|
70
|
+
if f.detail:
|
|
71
|
+
if _HAS_COLOR and not no_color:
|
|
72
|
+
click.echo(f" {Fore.WHITE}{f.detail}{Style.RESET_ALL}")
|
|
73
|
+
else:
|
|
74
|
+
click.echo(f" {f.detail}")
|
|
75
|
+
click.echo()
|
|
76
|
+
|
|
77
|
+
counts = summarize(findings)
|
|
78
|
+
total = sum(counts.values())
|
|
79
|
+
|
|
80
|
+
parts = []
|
|
81
|
+
if counts[SEVERITY_CRITICAL]:
|
|
82
|
+
parts.append(_color(f"{counts[SEVERITY_CRITICAL]} critical", SEVERITY_CRITICAL, no_color))
|
|
83
|
+
if counts[SEVERITY_WARNING]:
|
|
84
|
+
parts.append(_color(f"{counts[SEVERITY_WARNING]} warning(s)", SEVERITY_WARNING, no_color))
|
|
85
|
+
if counts[SEVERITY_INFO]:
|
|
86
|
+
parts.append(_color(f"{counts[SEVERITY_INFO]} info", SEVERITY_INFO, no_color))
|
|
87
|
+
|
|
88
|
+
click.echo(f"Summary: {total} issue(s) found — " + ", ".join(parts))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@click.group()
|
|
92
|
+
@click.version_option(version=__version__, prog_name="compose-auditor")
|
|
93
|
+
def cli() -> None:
|
|
94
|
+
"""Docker Compose security and best-practice linter."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@cli.command()
|
|
98
|
+
@click.argument("compose_file", type=click.Path(exists=True, path_type=Path))
|
|
99
|
+
@click.option(
|
|
100
|
+
"--format",
|
|
101
|
+
"output_format",
|
|
102
|
+
type=click.Choice(["text", "json"]),
|
|
103
|
+
default="text",
|
|
104
|
+
show_default=True,
|
|
105
|
+
help="Output format.",
|
|
106
|
+
)
|
|
107
|
+
@click.option(
|
|
108
|
+
"--no-color",
|
|
109
|
+
is_flag=True,
|
|
110
|
+
default=False,
|
|
111
|
+
help="Disable colored output.",
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--fail-on",
|
|
115
|
+
"fail_on",
|
|
116
|
+
type=click.Choice(["critical", "warning", "info", "never"]),
|
|
117
|
+
default="critical",
|
|
118
|
+
show_default=True,
|
|
119
|
+
help="Exit with code 1 when findings at this level or above are found.",
|
|
120
|
+
)
|
|
121
|
+
@click.option(
|
|
122
|
+
"--profile",
|
|
123
|
+
"profile",
|
|
124
|
+
type=click.Choice(sorted(PROFILES.keys())),
|
|
125
|
+
default=None,
|
|
126
|
+
help="Audit profile — adjusts severity for context (e.g., homelab suppresses noise).",
|
|
127
|
+
)
|
|
128
|
+
@click.option(
|
|
129
|
+
"--config",
|
|
130
|
+
"config_path",
|
|
131
|
+
type=click.Path(exists=True, path_type=Path),
|
|
132
|
+
default=None,
|
|
133
|
+
help="Path to .compose-auditor.yml config file.",
|
|
134
|
+
)
|
|
135
|
+
@click.option(
|
|
136
|
+
"--ignore",
|
|
137
|
+
"ignore_rules",
|
|
138
|
+
multiple=True,
|
|
139
|
+
help="Rule IDs to ignore (can be repeated, e.g., --ignore SEC002 --ignore VOL001).",
|
|
140
|
+
)
|
|
141
|
+
def lint(
|
|
142
|
+
compose_file: Path,
|
|
143
|
+
output_format: str,
|
|
144
|
+
no_color: bool,
|
|
145
|
+
fail_on: str,
|
|
146
|
+
profile: str | None,
|
|
147
|
+
config_path: Path | None,
|
|
148
|
+
ignore_rules: tuple[str, ...],
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Lint a docker-compose file for security and best-practice issues."""
|
|
151
|
+
# Build config: file < profile flag < --ignore flags
|
|
152
|
+
audit_config = load_config(config_path)
|
|
153
|
+
|
|
154
|
+
if profile:
|
|
155
|
+
audit_config.profile = profile
|
|
156
|
+
if profile in PROFILES:
|
|
157
|
+
audit_config.severity_overrides.update(PROFILES[profile])
|
|
158
|
+
|
|
159
|
+
for rule_id in ignore_rules:
|
|
160
|
+
audit_config.global_ignore.add(rule_id)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
findings = analyze(compose_file, audit_config)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
click.echo(f"Error parsing {compose_file}: {exc}", err=True)
|
|
166
|
+
sys.exit(2)
|
|
167
|
+
|
|
168
|
+
counts = summarize(findings)
|
|
169
|
+
|
|
170
|
+
if output_format == "json":
|
|
171
|
+
output = {
|
|
172
|
+
"file": str(compose_file),
|
|
173
|
+
"profile": audit_config.profile,
|
|
174
|
+
"summary": counts,
|
|
175
|
+
"findings": findings_to_dict(findings),
|
|
176
|
+
}
|
|
177
|
+
click.echo(json.dumps(output, indent=2))
|
|
178
|
+
else:
|
|
179
|
+
_print_text(findings, compose_file, no_color, audit_config.profile)
|
|
180
|
+
|
|
181
|
+
# Exit codes
|
|
182
|
+
fail_map = {
|
|
183
|
+
"never": [],
|
|
184
|
+
"info": [SEVERITY_CRITICAL, SEVERITY_WARNING, SEVERITY_INFO],
|
|
185
|
+
"warning": [SEVERITY_CRITICAL, SEVERITY_WARNING],
|
|
186
|
+
"critical": [SEVERITY_CRITICAL],
|
|
187
|
+
}
|
|
188
|
+
trigger_severities = fail_map[fail_on]
|
|
189
|
+
if any(f.severity in trigger_severities for f in findings):
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main() -> None:
|
|
194
|
+
cli()
|