compose-guard 0.1.0__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.
- compose_guard/__init__.py +5 -0
- compose_guard/__main__.py +6 -0
- compose_guard/cli.py +182 -0
- compose_guard/config.py +150 -0
- compose_guard/engine.py +73 -0
- compose_guard/errors.py +28 -0
- compose_guard/interpolation.py +20 -0
- compose_guard/loader.py +196 -0
- compose_guard/model.py +120 -0
- compose_guard/report/__init__.py +20 -0
- compose_guard/report/json_report.py +65 -0
- compose_guard/report/sarif.py +95 -0
- compose_guard/report/text.py +58 -0
- compose_guard/rules/__init__.py +41 -0
- compose_guard/rules/base.py +98 -0
- compose_guard/rules/images.py +43 -0
- compose_guard/rules/network.py +121 -0
- compose_guard/rules/parsing.py +301 -0
- compose_guard/rules/reliability.py +202 -0
- compose_guard/rules/resources.py +57 -0
- compose_guard/rules/security.py +273 -0
- compose_guard/rules/validation.py +91 -0
- compose_guard/suppress.py +68 -0
- compose_guard-0.1.0.dist-info/METADATA +449 -0
- compose_guard-0.1.0.dist-info/RECORD +28 -0
- compose_guard-0.1.0.dist-info/WHEEL +4 -0
- compose_guard-0.1.0.dist-info/entry_points.txt +2 -0
- compose_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
compose_guard/cli.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Command-line interface: argument parsing, file discovery and exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import dataclasses
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import NoReturn, TextIO
|
|
12
|
+
|
|
13
|
+
from compose_guard import __version__
|
|
14
|
+
from compose_guard.config import DEFAULT_CONFIG_NAME, FAIL_ON_CHOICES, Config, load_config
|
|
15
|
+
from compose_guard.engine import lint
|
|
16
|
+
from compose_guard.errors import ComposeGuardError, InputError
|
|
17
|
+
from compose_guard.loader import load_bytes
|
|
18
|
+
from compose_guard.model import ComposeFile
|
|
19
|
+
from compose_guard.report import FORMATS, render
|
|
20
|
+
from compose_guard.rules import RULES, RULES_BY_ID
|
|
21
|
+
|
|
22
|
+
PROG = "compose-guard"
|
|
23
|
+
|
|
24
|
+
EXIT_OK = 0
|
|
25
|
+
EXIT_FINDINGS = 1
|
|
26
|
+
EXIT_USAGE = 2
|
|
27
|
+
EXIT_INTERRUPTED = 130
|
|
28
|
+
EXIT_BROKEN_PIPE = 141
|
|
29
|
+
|
|
30
|
+
# The Compose specification's lookup order for a project's default file.
|
|
31
|
+
DEFAULT_FILES = ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml")
|
|
32
|
+
|
|
33
|
+
STDIN_NAME = "<stdin>"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Parser(argparse.ArgumentParser):
|
|
37
|
+
def error(self, message: str) -> NoReturn:
|
|
38
|
+
self.print_usage(sys.stderr)
|
|
39
|
+
self.exit(EXIT_USAGE, f"{self.prog}: error: {message}\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
DESCRIPTION = "Lint Docker Compose files for security, misconfiguration and reliability problems."
|
|
43
|
+
EPILOG = (
|
|
44
|
+
"exit codes: 0 no unsuppressed finding at or above --fail-on, 1 at least one such finding, "
|
|
45
|
+
"2 usage or input error, 130 interrupted, 141 output pipe closed"
|
|
46
|
+
)
|
|
47
|
+
FILES_HELP = (
|
|
48
|
+
f"Compose files to lint; '-' reads standard input (default: the first of "
|
|
49
|
+
f"{', '.join(DEFAULT_FILES)} in the current directory)"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
54
|
+
parser = _Parser(prog=PROG, description=DESCRIPTION, epilog=EPILOG)
|
|
55
|
+
parser.add_argument("files", nargs="*", metavar="FILE", help=FILES_HELP)
|
|
56
|
+
parser.add_argument("--version", action="version", version=f"{PROG} {__version__}")
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--format", choices=FORMATS, default="text", help="output format (default: text)"
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--fail-on",
|
|
62
|
+
choices=FAIL_ON_CHOICES,
|
|
63
|
+
default=None,
|
|
64
|
+
help="exit 1 if an unsuppressed finding has this severity or worse (default: error)",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--config",
|
|
68
|
+
metavar="PATH",
|
|
69
|
+
type=Path,
|
|
70
|
+
help=f"configuration file (default: {DEFAULT_CONFIG_NAME} in the current directory)",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--ignore",
|
|
74
|
+
metavar="IDS",
|
|
75
|
+
action="append",
|
|
76
|
+
default=[],
|
|
77
|
+
help="comma-separated rule IDs to ignore, added to the config file's list",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--show-suppressed", action="store_true", help="also list suppressed findings"
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument("--list-rules", action="store_true", help="describe every rule and exit")
|
|
83
|
+
return parser
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_ignore(parser: argparse.ArgumentParser, values: Sequence[str]) -> frozenset[str]:
|
|
87
|
+
ids = {part.strip().upper() for value in values for part in value.split(",") if part.strip()}
|
|
88
|
+
unknown = sorted(ids - set(RULES_BY_ID))
|
|
89
|
+
if unknown:
|
|
90
|
+
parser.error(
|
|
91
|
+
f"unknown rule ID(s) for --ignore: {', '.join(unknown)} "
|
|
92
|
+
f"(valid: {', '.join(RULES_BY_ID)})"
|
|
93
|
+
)
|
|
94
|
+
return frozenset(ids)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _discover(parser: argparse.ArgumentParser) -> list[str]:
|
|
98
|
+
for name in DEFAULT_FILES:
|
|
99
|
+
if Path(name).is_file():
|
|
100
|
+
return [name]
|
|
101
|
+
parser.error(f"no Compose file given and none of {', '.join(DEFAULT_FILES)} found")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read(path: str, stdin: TextIO) -> ComposeFile:
|
|
105
|
+
if path == "-":
|
|
106
|
+
return load_bytes(stdin.buffer.read(), STDIN_NAME)
|
|
107
|
+
target = Path(path)
|
|
108
|
+
# Windows raises PermissionError for directories, not IsADirectoryError.
|
|
109
|
+
if target.is_dir():
|
|
110
|
+
raise InputError("is a directory", file=path)
|
|
111
|
+
try:
|
|
112
|
+
data = target.read_bytes()
|
|
113
|
+
except FileNotFoundError:
|
|
114
|
+
raise InputError("no such file", file=path) from None
|
|
115
|
+
except IsADirectoryError:
|
|
116
|
+
raise InputError("is a directory", file=path) from None
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
raise InputError(f"cannot read file: {exc.strerror}", file=path) from None
|
|
119
|
+
return load_bytes(data, path)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _config(args: argparse.Namespace) -> Config:
|
|
123
|
+
if args.config is not None:
|
|
124
|
+
return load_config(args.config, RULES_BY_ID)
|
|
125
|
+
default = Path(DEFAULT_CONFIG_NAME)
|
|
126
|
+
if default.is_file():
|
|
127
|
+
return load_config(default, RULES_BY_ID)
|
|
128
|
+
return Config()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def list_rules() -> str:
|
|
132
|
+
blocks = []
|
|
133
|
+
for rule in RULES:
|
|
134
|
+
blocks.append(
|
|
135
|
+
f"{rule.id} {rule.severity.value:<7} {rule.title}\n"
|
|
136
|
+
f" {rule.rationale}\n"
|
|
137
|
+
f" fix: {rule.fix}"
|
|
138
|
+
)
|
|
139
|
+
return "\n\n".join(blocks) + "\n"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run(argv: Sequence[str] | None, stdout: TextIO, stdin: TextIO) -> int:
|
|
143
|
+
parser = build_parser()
|
|
144
|
+
args = parser.parse_args(argv)
|
|
145
|
+
if args.list_rules:
|
|
146
|
+
stdout.write(list_rules())
|
|
147
|
+
return EXIT_OK
|
|
148
|
+
|
|
149
|
+
cli_ignores = _parse_ignore(parser, args.ignore)
|
|
150
|
+
paths = args.files or _discover(parser)
|
|
151
|
+
if paths.count("-") > 1:
|
|
152
|
+
parser.error("standard input ('-') can only be given once")
|
|
153
|
+
|
|
154
|
+
file_config = _config(args)
|
|
155
|
+
# Command-line flags add to (--ignore) or override (--fail-on) the config file.
|
|
156
|
+
config = dataclasses.replace(
|
|
157
|
+
file_config,
|
|
158
|
+
ignore=file_config.ignore | cli_ignores,
|
|
159
|
+
fail_on=args.fail_on or file_config.fail_on,
|
|
160
|
+
)
|
|
161
|
+
files = [_read(path, stdin) for path in paths]
|
|
162
|
+
report = lint(files, config)
|
|
163
|
+
stdout.write(render(report, args.format, show_suppressed=args.show_suppressed))
|
|
164
|
+
stdout.flush()
|
|
165
|
+
return EXIT_FINDINGS if report.fails(config.fail_on or "error") else EXIT_OK
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
169
|
+
try:
|
|
170
|
+
return _run(argv, sys.stdout, sys.stdin)
|
|
171
|
+
except ComposeGuardError as exc:
|
|
172
|
+
sys.stderr.write(f"{PROG}: error: {exc}\n")
|
|
173
|
+
return EXIT_USAGE
|
|
174
|
+
except KeyboardInterrupt:
|
|
175
|
+
sys.stderr.write(f"{PROG}: interrupted\n")
|
|
176
|
+
return EXIT_INTERRUPTED
|
|
177
|
+
except BrokenPipeError:
|
|
178
|
+
# The reader went away (e.g. `| head`). Point stdout at devnull so the interpreter's
|
|
179
|
+
# final flush does not raise again, and exit quietly.
|
|
180
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
181
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
182
|
+
return EXIT_BROKEN_PIPE
|
compose_guard/config.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The ``.compose-guard.yml`` configuration file.
|
|
2
|
+
|
|
3
|
+
```yaml
|
|
4
|
+
ignore: [CD015]
|
|
5
|
+
services:
|
|
6
|
+
worker:
|
|
7
|
+
ignore: [CD007]
|
|
8
|
+
severity:
|
|
9
|
+
CD010: warning
|
|
10
|
+
fail_on: warning
|
|
11
|
+
```
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Collection, Mapping
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from compose_guard.errors import InputError
|
|
21
|
+
from compose_guard.loader import parse_mapping
|
|
22
|
+
from compose_guard.model import Located, LocatedMap, Severity, Value
|
|
23
|
+
from compose_guard.rules.base import mapping, sequence, text
|
|
24
|
+
|
|
25
|
+
DEFAULT_CONFIG_NAME = ".compose-guard.yml"
|
|
26
|
+
FAIL_ON_CHOICES = ("error", "warning", "note", "never")
|
|
27
|
+
|
|
28
|
+
_TOP_LEVEL_KEYS = ("ignore", "services", "severity", "fail_on")
|
|
29
|
+
_SERVICE_KEYS = ("ignore",)
|
|
30
|
+
_SEVERITIES = tuple(severity.value for severity in Severity)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Config:
|
|
35
|
+
ignore: frozenset[str] = frozenset()
|
|
36
|
+
service_ignores: Mapping[str, frozenset[str]] = field(default_factory=dict)
|
|
37
|
+
severity: Mapping[str, Severity] = field(default_factory=dict)
|
|
38
|
+
fail_on: str | None = None
|
|
39
|
+
|
|
40
|
+
def ignores(self, rule_id: str, service: str | None) -> bool:
|
|
41
|
+
"""Whether ``rule_id`` is ignored globally or for ``service``."""
|
|
42
|
+
if rule_id in self.ignore:
|
|
43
|
+
return True
|
|
44
|
+
return service is not None and rule_id in self.service_ignores.get(service, frozenset())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config(path: Path, known_rules: Collection[str]) -> Config:
|
|
48
|
+
"""Read and validate a config file. Raises :class:`InputError` naming the problem."""
|
|
49
|
+
display = str(path)
|
|
50
|
+
try:
|
|
51
|
+
data = path.read_bytes()
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
raise InputError("config file not found", file=display) from None
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
raise InputError(f"cannot read config file: {exc.strerror}", file=display) from None
|
|
56
|
+
try:
|
|
57
|
+
source = data.decode("utf-8-sig")
|
|
58
|
+
except UnicodeDecodeError:
|
|
59
|
+
raise InputError("config file is not valid UTF-8", file=display) from None
|
|
60
|
+
root = parse_mapping(source, display)
|
|
61
|
+
if root is None:
|
|
62
|
+
return Config()
|
|
63
|
+
return _Parser(display, known_rules).parse(root)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Parser:
|
|
67
|
+
def __init__(self, path: str, known_rules: Collection[str]) -> None:
|
|
68
|
+
self._path = path
|
|
69
|
+
self._known = known_rules
|
|
70
|
+
|
|
71
|
+
def _error(self, message: str, at: Located[object]) -> InputError:
|
|
72
|
+
return InputError(message, file=self._path, line=at.line)
|
|
73
|
+
|
|
74
|
+
def _check_keys(self, raw: LocatedMap, allowed: tuple[str, ...], where: str) -> None:
|
|
75
|
+
for name in raw:
|
|
76
|
+
if name not in allowed:
|
|
77
|
+
raise self._error(
|
|
78
|
+
f"unknown key '{name}' in {where}; valid keys are: {', '.join(allowed)}",
|
|
79
|
+
raw.key(name),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _rule_id(self, value: Located[Value]) -> str:
|
|
83
|
+
rule_id = text(value)
|
|
84
|
+
if rule_id is None or rule_id not in self._known:
|
|
85
|
+
shown = rule_id if rule_id is not None else "(not a string)"
|
|
86
|
+
raise self._error(
|
|
87
|
+
f"unknown rule ID '{shown}'; valid IDs are: {', '.join(sorted(self._known))}",
|
|
88
|
+
value,
|
|
89
|
+
)
|
|
90
|
+
return rule_id
|
|
91
|
+
|
|
92
|
+
def _rule_list(self, value: Located[Value], where: str) -> frozenset[str]:
|
|
93
|
+
items = sequence(value)
|
|
94
|
+
if items is None:
|
|
95
|
+
raise self._error(f"'{where}' must be a list of rule IDs", value)
|
|
96
|
+
return frozenset(self._rule_id(item) for item in items)
|
|
97
|
+
|
|
98
|
+
def parse(self, root: LocatedMap) -> Config:
|
|
99
|
+
self._check_keys(root, _TOP_LEVEL_KEYS, "config file")
|
|
100
|
+
ignore = self._rule_list(root["ignore"], "ignore") if "ignore" in root else frozenset()
|
|
101
|
+
return Config(
|
|
102
|
+
ignore=ignore,
|
|
103
|
+
service_ignores=self._services(root.get("services")),
|
|
104
|
+
severity=self._severity(root.get("severity")),
|
|
105
|
+
fail_on=self._fail_on(root.get("fail_on")),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _services(self, value: Located[Value] | None) -> dict[str, frozenset[str]]:
|
|
109
|
+
if value is None:
|
|
110
|
+
return {}
|
|
111
|
+
services = mapping(value)
|
|
112
|
+
if services is None:
|
|
113
|
+
raise self._error("'services' must be a mapping of service names", value)
|
|
114
|
+
result = {}
|
|
115
|
+
for name in services:
|
|
116
|
+
settings = mapping(services[name])
|
|
117
|
+
if settings is None:
|
|
118
|
+
raise self._error(f"'services.{name}' must be a mapping", services[name])
|
|
119
|
+
self._check_keys(settings, _SERVICE_KEYS, f"'services.{name}'")
|
|
120
|
+
if "ignore" in settings:
|
|
121
|
+
result[name] = self._rule_list(settings["ignore"], f"services.{name}.ignore")
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
def _severity(self, value: Located[Value] | None) -> dict[str, Severity]:
|
|
125
|
+
if value is None:
|
|
126
|
+
return {}
|
|
127
|
+
overrides = mapping(value)
|
|
128
|
+
if overrides is None:
|
|
129
|
+
raise self._error("'severity' must be a mapping of rule IDs to severities", value)
|
|
130
|
+
result = {}
|
|
131
|
+
for name in overrides:
|
|
132
|
+
rule_id = self._rule_id(overrides.key(name))
|
|
133
|
+
level = text(overrides[name])
|
|
134
|
+
if level not in _SEVERITIES:
|
|
135
|
+
raise self._error(
|
|
136
|
+
f"invalid severity for {rule_id}; valid values are: {', '.join(_SEVERITIES)}",
|
|
137
|
+
overrides[name],
|
|
138
|
+
)
|
|
139
|
+
result[rule_id] = Severity(level)
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
def _fail_on(self, value: Located[Value] | None) -> str | None:
|
|
143
|
+
if value is None:
|
|
144
|
+
return None
|
|
145
|
+
level = text(value)
|
|
146
|
+
if level not in FAIL_ON_CHOICES:
|
|
147
|
+
raise self._error(
|
|
148
|
+
f"invalid 'fail_on'; valid values are: {', '.join(FAIL_ON_CHOICES)}", value
|
|
149
|
+
)
|
|
150
|
+
return level
|
compose_guard/engine.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Lint loaded files: run rules, apply severity overrides and suppressions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from compose_guard.config import Config
|
|
11
|
+
from compose_guard.model import ComposeFile, Finding, Severity
|
|
12
|
+
from compose_guard.rules import RULES_BY_ID, run_rules, sort_key
|
|
13
|
+
from compose_guard.suppress import parse_suppressions
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Suppression(Enum):
|
|
17
|
+
"""Why a finding was suppressed. Values are SARIF ``suppression.kind`` values."""
|
|
18
|
+
|
|
19
|
+
INLINE = "inSource"
|
|
20
|
+
CONFIG = "external"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Result:
|
|
25
|
+
finding: Finding
|
|
26
|
+
suppression: Suppression | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Report:
|
|
31
|
+
files: tuple[str, ...]
|
|
32
|
+
results: tuple[Result, ...]
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def findings(self) -> list[Finding]:
|
|
36
|
+
"""Unsuppressed findings."""
|
|
37
|
+
return [r.finding for r in self.results if r.suppression is None]
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def suppressed(self) -> list[Result]:
|
|
41
|
+
return [r for r in self.results if r.suppression is not None]
|
|
42
|
+
|
|
43
|
+
def count(self, severity: Severity) -> int:
|
|
44
|
+
return sum(1 for finding in self.findings if finding.severity is severity)
|
|
45
|
+
|
|
46
|
+
def fails(self, fail_on: str) -> bool:
|
|
47
|
+
"""Whether any unsuppressed finding is at or above ``fail_on`` (or never, for "never")."""
|
|
48
|
+
if fail_on == "never":
|
|
49
|
+
return False
|
|
50
|
+
threshold = Severity(fail_on).rank
|
|
51
|
+
return any(finding.severity.rank >= threshold for finding in self.findings)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def lint_file(compose: ComposeFile, config: Config) -> list[Result]:
|
|
55
|
+
inline = parse_suppressions(compose.source, compose.path, RULES_BY_ID)
|
|
56
|
+
results = []
|
|
57
|
+
for finding in run_rules(compose):
|
|
58
|
+
override = config.severity.get(finding.rule_id)
|
|
59
|
+
if override is not None:
|
|
60
|
+
finding = dataclasses.replace(finding, severity=override)
|
|
61
|
+
if inline.covers(finding.rule_id, finding.line):
|
|
62
|
+
results.append(Result(finding, Suppression.INLINE))
|
|
63
|
+
elif config.ignores(finding.rule_id, finding.service):
|
|
64
|
+
results.append(Result(finding, Suppression.CONFIG))
|
|
65
|
+
else:
|
|
66
|
+
results.append(Result(finding))
|
|
67
|
+
return results
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def lint(files: Sequence[ComposeFile], config: Config) -> Report:
|
|
71
|
+
results = [result for compose in files for result in lint_file(compose, config)]
|
|
72
|
+
results.sort(key=lambda result: sort_key(result.finding))
|
|
73
|
+
return Report(files=tuple(compose.path for compose in files), results=tuple(results))
|
compose_guard/errors.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Exception types raised by Compose Guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ComposeGuardError(Exception):
|
|
7
|
+
"""Base class for all errors Compose Guard reports to the user."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InputError(ComposeGuardError):
|
|
11
|
+
"""A problem with an input file or configuration.
|
|
12
|
+
|
|
13
|
+
``file`` and ``line`` locate the problem when known. ``str()`` renders the
|
|
14
|
+
``<file>:<line>: <message>`` form used on stderr.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, file: str | None = None, line: int | None = None) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.message = message
|
|
20
|
+
self.file = file
|
|
21
|
+
self.line = line
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
if self.file is None:
|
|
25
|
+
return self.message
|
|
26
|
+
if self.line is None:
|
|
27
|
+
return f"{self.file}: {self.message}"
|
|
28
|
+
return f"{self.file}:{self.line}: {self.message}"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Detect Compose variable interpolation (detection only, values are never resolved).
|
|
2
|
+
|
|
3
|
+
Compose substitutes ``${VAR}``, ``${VAR:-default}``, ``${VAR-default}``, ``${VAR:?err}``,
|
|
4
|
+
``${VAR?err}``, ``${VAR:+alt}``, ``${VAR+alt}`` and ``$VAR``. ``$$`` is an escaped literal
|
|
5
|
+
``$``. Rules treat an interpolated value as unknown.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_NAME = r"[A-Za-z_][A-Za-z0-9_]*"
|
|
13
|
+
|
|
14
|
+
# "$$" is matched first so an escaped dollar is consumed before it can start a reference.
|
|
15
|
+
_TOKEN = re.compile(rf"\$\$|\$\{{{_NAME}(?:(?::?[-?+])[^}}]*)?\}}|\${_NAME}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def contains_interpolation(text: str) -> bool:
|
|
19
|
+
"""Return True if ``text`` contains at least one variable reference."""
|
|
20
|
+
return any(match.group() != "$$" for match in _TOKEN.finditer(text))
|
compose_guard/loader.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Load Compose files into located values.
|
|
2
|
+
|
|
3
|
+
PyYAML's ``load`` functions throw away source positions, so this module calls ``yaml.compose``
|
|
4
|
+
with ``SafeLoader`` to get a node tree with marks and converts the nodes itself. Aliases are
|
|
5
|
+
already shared nodes in that tree; merge keys (``<<``) are applied here with YAML 1.1 semantics.
|
|
6
|
+
No Python objects other than plain scalars, ``LocatedMap`` and tuples are ever constructed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from yaml.constructor import SafeConstructor
|
|
15
|
+
from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
|
|
16
|
+
|
|
17
|
+
from compose_guard.errors import InputError
|
|
18
|
+
from compose_guard.model import ComposeFile, Located, LocatedMap, Scalar, Service, Value
|
|
19
|
+
|
|
20
|
+
MAX_NODES = 100_000
|
|
21
|
+
"""Upper bound on values produced while converting one document (alias-bomb protection)."""
|
|
22
|
+
|
|
23
|
+
MAX_DEPTH = 100
|
|
24
|
+
"""Upper bound on nesting depth, far beyond anything a real Compose file needs."""
|
|
25
|
+
|
|
26
|
+
_TAG = "tag:yaml.org,2002:"
|
|
27
|
+
_MERGE_TAG = _TAG + "merge"
|
|
28
|
+
|
|
29
|
+
_constructor = SafeConstructor()
|
|
30
|
+
|
|
31
|
+
# Only these scalar constructors are used. Any other tag (timestamps, binary, Compose's own
|
|
32
|
+
# ``!reset`` / ``!override``) keeps its source text as a string.
|
|
33
|
+
_SCALAR_CONSTRUCTORS: dict[str, Callable[[ScalarNode], Scalar]] = {
|
|
34
|
+
_TAG + "null": lambda node: None,
|
|
35
|
+
_TAG + "bool": _constructor.construct_yaml_bool,
|
|
36
|
+
_TAG + "int": _constructor.construct_yaml_int,
|
|
37
|
+
_TAG + "float": _constructor.construct_yaml_float,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_bytes(data: bytes, path: str) -> ComposeFile:
|
|
42
|
+
"""Decode ``data`` as UTF-8 (a leading BOM is allowed) and load it."""
|
|
43
|
+
try:
|
|
44
|
+
text = data.decode("utf-8-sig")
|
|
45
|
+
except UnicodeDecodeError as exc:
|
|
46
|
+
line = data[: exc.start].count(b"\n") + 1
|
|
47
|
+
raise InputError("file is not valid UTF-8", file=path, line=line) from None
|
|
48
|
+
return load_text(text, path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_text(text: str, path: str) -> ComposeFile:
|
|
52
|
+
"""Parse ``text`` and build a :class:`ComposeFile`. Raises :class:`InputError`."""
|
|
53
|
+
root = parse_mapping(text, path)
|
|
54
|
+
if root is None:
|
|
55
|
+
raise InputError("file is empty", file=path, line=1)
|
|
56
|
+
return ComposeFile(path=path, source=text, root=root, services=_services(root, path))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_mapping(text: str, path: str) -> LocatedMap | None:
|
|
60
|
+
"""Parse a single YAML document whose top level must be a mapping.
|
|
61
|
+
|
|
62
|
+
Returns ``None`` for an empty document. Used for Compose files and the config file alike.
|
|
63
|
+
"""
|
|
64
|
+
root_node = _compose(text, path)
|
|
65
|
+
if root_node is None:
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(root_node, MappingNode):
|
|
68
|
+
raise InputError("top-level value must be a mapping", file=path, line=_line(root_node))
|
|
69
|
+
root = _Converter(path).convert(root_node)
|
|
70
|
+
assert isinstance(root.value, LocatedMap) # a MappingNode always converts to a LocatedMap
|
|
71
|
+
return root.value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _compose(text: str, path: str) -> Node | None:
|
|
75
|
+
try:
|
|
76
|
+
node: Node | None = yaml.compose(text, Loader=yaml.SafeLoader)
|
|
77
|
+
except yaml.MarkedYAMLError as exc:
|
|
78
|
+
mark = exc.problem_mark or exc.context_mark
|
|
79
|
+
line = mark.line + 1 if mark is not None else None
|
|
80
|
+
if exc.context == "expected a single document in the stream":
|
|
81
|
+
raise InputError(
|
|
82
|
+
"multiple YAML documents in one file are not supported", file=path, line=line
|
|
83
|
+
) from None
|
|
84
|
+
raise InputError(f"invalid YAML: {exc.problem}", file=path, line=line) from None
|
|
85
|
+
except yaml.YAMLError as exc: # a reader error without a mark
|
|
86
|
+
raise InputError(f"invalid YAML: {exc}", file=path) from None
|
|
87
|
+
except RecursionError:
|
|
88
|
+
raise InputError("document is nested too deeply", file=path) from None
|
|
89
|
+
return node
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _services(root: LocatedMap, path: str) -> tuple[Service, ...]:
|
|
93
|
+
if "services" not in root:
|
|
94
|
+
raise InputError("missing top-level 'services' key", file=path, line=1)
|
|
95
|
+
services_key, services_value = root.entry("services")
|
|
96
|
+
if not isinstance(services_value.value, LocatedMap):
|
|
97
|
+
raise InputError("'services' must be a mapping", file=path, line=services_key.line)
|
|
98
|
+
|
|
99
|
+
services = []
|
|
100
|
+
for name in services_value.value:
|
|
101
|
+
if name.startswith("x-"):
|
|
102
|
+
continue
|
|
103
|
+
key, value = services_value.value.entry(name)
|
|
104
|
+
if not isinstance(value.value, LocatedMap):
|
|
105
|
+
raise InputError(f"service '{name}' must be a mapping", file=path, line=key.line)
|
|
106
|
+
services.append(Service(name=name, line=key.line, column=key.column, raw=value.value))
|
|
107
|
+
return tuple(services)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _line(node: Node) -> int:
|
|
111
|
+
return node.start_mark.line + 1
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _column(node: Node) -> int:
|
|
115
|
+
return node.start_mark.column + 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _Converter:
|
|
119
|
+
"""Convert a composed node tree to located values, counting what it produces."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, path: str) -> None:
|
|
122
|
+
self._path = path
|
|
123
|
+
self._count = 0
|
|
124
|
+
self._active: set[int] = set()
|
|
125
|
+
|
|
126
|
+
def convert(self, node: Node, depth: int = 0) -> Located[Value]:
|
|
127
|
+
self._count += 1
|
|
128
|
+
if self._count > MAX_NODES:
|
|
129
|
+
raise InputError("document expands to too many nodes", file=self._path)
|
|
130
|
+
if depth > MAX_DEPTH:
|
|
131
|
+
raise InputError("document is nested too deeply", file=self._path, line=_line(node))
|
|
132
|
+
|
|
133
|
+
if isinstance(node, ScalarNode):
|
|
134
|
+
return Located(_scalar(node), _line(node), _column(node), raw=node.value)
|
|
135
|
+
|
|
136
|
+
if id(node) in self._active:
|
|
137
|
+
raise InputError("recursive alias", file=self._path, line=_line(node))
|
|
138
|
+
self._active.add(id(node))
|
|
139
|
+
try:
|
|
140
|
+
value: Value
|
|
141
|
+
if isinstance(node, MappingNode):
|
|
142
|
+
value = self._mapping(node, depth)
|
|
143
|
+
else:
|
|
144
|
+
assert isinstance(node, SequenceNode) # the only remaining node kind
|
|
145
|
+
value = tuple(self.convert(item, depth + 1) for item in node.value)
|
|
146
|
+
finally:
|
|
147
|
+
self._active.discard(id(node))
|
|
148
|
+
return Located(value, _line(node), _column(node))
|
|
149
|
+
|
|
150
|
+
def _mapping(self, node: MappingNode, depth: int) -> LocatedMap:
|
|
151
|
+
merged: dict[str, tuple[Located[str], Located[Value]]] = {}
|
|
152
|
+
explicit: dict[str, tuple[Located[str], Located[Value]]] = {}
|
|
153
|
+
|
|
154
|
+
for key_node, value_node in node.value:
|
|
155
|
+
if key_node.tag == _MERGE_TAG:
|
|
156
|
+
# Earlier merge sources win over later ones, so only fill keys not yet seen.
|
|
157
|
+
for source in self._merge_sources(value_node, depth):
|
|
158
|
+
for name in source:
|
|
159
|
+
merged.setdefault(name, source.entry(name))
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
key = self._key(key_node)
|
|
163
|
+
if key.value in explicit:
|
|
164
|
+
raise InputError(f"duplicate key '{key.value}'", file=self._path, line=key.line)
|
|
165
|
+
explicit[key.value] = (key, self.convert(value_node, depth + 1))
|
|
166
|
+
|
|
167
|
+
# Keys written in the mapping itself win over merged keys.
|
|
168
|
+
return LocatedMap({**merged, **explicit})
|
|
169
|
+
|
|
170
|
+
def _merge_sources(self, node: Node, depth: int) -> Iterable[LocatedMap]:
|
|
171
|
+
items = node.value if isinstance(node, SequenceNode) else [node]
|
|
172
|
+
sources = []
|
|
173
|
+
for item in items:
|
|
174
|
+
if not isinstance(item, MappingNode):
|
|
175
|
+
raise InputError(
|
|
176
|
+
"merge key '<<' must refer to a mapping or a list of mappings",
|
|
177
|
+
file=self._path,
|
|
178
|
+
line=_line(item),
|
|
179
|
+
)
|
|
180
|
+
converted = self.convert(item, depth + 1).value
|
|
181
|
+
assert isinstance(converted, LocatedMap) # a MappingNode always converts to one
|
|
182
|
+
sources.append(converted)
|
|
183
|
+
return sources
|
|
184
|
+
|
|
185
|
+
def _key(self, node: Node) -> Located[str]:
|
|
186
|
+
if not isinstance(node, ScalarNode):
|
|
187
|
+
raise InputError("mapping keys must be scalars", file=self._path, line=_line(node))
|
|
188
|
+
# Keys keep their source text: ``on:`` stays "on" instead of becoming True.
|
|
189
|
+
return Located(node.value, _line(node), _column(node), raw=node.value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _scalar(node: ScalarNode) -> Scalar:
|
|
193
|
+
construct = _SCALAR_CONSTRUCTORS.get(node.tag)
|
|
194
|
+
if construct is None:
|
|
195
|
+
return str(node.value)
|
|
196
|
+
return construct(node)
|