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.
@@ -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,3 @@
1
+ """compose-auditor: Docker Compose security and best-practice linter."""
2
+
3
+ __version__ = "0.2.0"
@@ -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()