composable-data-stack 0.4.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.
- cli/__init__.py +1 -0
- cli/diagnostics.py +13 -0
- cli/graph.py +58 -0
- cli/image_updates.py +326 -0
- cli/image_verification.py +484 -0
- cli/loader.py +180 -0
- cli/main.py +1656 -0
- cli/overlay.py +239 -0
- cli/planner.py +618 -0
- cli/preflight.py +418 -0
- cli/renderer.py +791 -0
- cli/resolver.py +28 -0
- cli/resources/__init__.py +1 -0
- cli/resources/rule-schema.json +274 -0
- cli/resources/rule-set.json +919 -0
- cli/secrets.py +169 -0
- cli/security.py +768 -0
- cli/security_common.py +41 -0
- cli/state.py +112 -0
- cli/up_runner.py +257 -0
- cli/validator.py +570 -0
- composable_data_stack-0.4.0.dist-info/METADATA +872 -0
- composable_data_stack-0.4.0.dist-info/RECORD +27 -0
- composable_data_stack-0.4.0.dist-info/WHEEL +5 -0
- composable_data_stack-0.4.0.dist-info/entry_points.txt +2 -0
- composable_data_stack-0.4.0.dist-info/licenses/LICENSE +201 -0
- composable_data_stack-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# cli/image_verification.py
|
|
2
|
+
"""
|
|
3
|
+
OCI image signature and build provenance verification.
|
|
4
|
+
|
|
5
|
+
Implements the CDS production image policy (issue #208) on top of the
|
|
6
|
+
publication and attestation flow in .github/workflows/publish-images.yml:
|
|
7
|
+
|
|
8
|
+
- Static policy checks over rendered Compose service images: trusted
|
|
9
|
+
registry allowlist, digest pinning for production, and no floating
|
|
10
|
+
":latest" tags (the intent of the deferred CDS-SEC-050/051/052/054 rules).
|
|
11
|
+
- Cosign-compatible signature and provenance verification, pluggable via
|
|
12
|
+
CDS_COSIGN_BIN. Keyless by default (OIDC issuer + certificate identity
|
|
13
|
+
constraints); key-managed when CDS_COSIGN_KEY points at a key file.
|
|
14
|
+
- Offline verification against a known-good fixture
|
|
15
|
+
(tests/fixtures/signed-images.json) so CI can verify without a registry
|
|
16
|
+
round trip or a cosign binary.
|
|
17
|
+
|
|
18
|
+
The policy is gated by CDS_IMAGE_VERIFICATION (off | policy | full);
|
|
19
|
+
production profiles default to "policy", and "full" additionally requires
|
|
20
|
+
signature/provenance verification to succeed.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess # nosec B404
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, cast
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
from .image_updates import parse_image_reference
|
|
36
|
+
from .security_common import SEVERITY_ORDER
|
|
37
|
+
|
|
38
|
+
DEFAULT_TRUSTED_REGISTRIES = ("ghcr.io", "docker.io", "registry-1.docker.io")
|
|
39
|
+
DEFAULT_TRUSTED_OIDC_ISSUER = "https://token.actions.githubusercontent.com"
|
|
40
|
+
DEFAULT_CERT_IDENTITY_REGEXP = (
|
|
41
|
+
r"^https://github\.com/RonaldHensbergen/composable-data-stack/"
|
|
42
|
+
r"\.github/workflows/publish-images\.yml@refs/heads/main$"
|
|
43
|
+
)
|
|
44
|
+
DEFAULT_COSIGN_BIN = "cosign"
|
|
45
|
+
FIXTURE_RELATIVE_PATH = Path("tests/fixtures/signed-images.json")
|
|
46
|
+
_FIXTURE_ENV_VAR = "CDS_SIGNED_IMAGES_FIXTURE"
|
|
47
|
+
|
|
48
|
+
_DIGEST_PATTERN = re.compile(r"^sha256:[a-f0-9]{64}$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ImagePolicy:
|
|
53
|
+
"""Trust constraints and tooling for image verification."""
|
|
54
|
+
|
|
55
|
+
mode: str = "off"
|
|
56
|
+
trusted_registries: tuple[str, ...] = DEFAULT_TRUSTED_REGISTRIES
|
|
57
|
+
oidc_issuer: str = DEFAULT_TRUSTED_OIDC_ISSUER
|
|
58
|
+
cert_identity_regexp: str = DEFAULT_CERT_IDENTITY_REGEXP
|
|
59
|
+
cosign_bin: str = DEFAULT_COSIGN_BIN
|
|
60
|
+
key_path: str | None = None
|
|
61
|
+
require_digest: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_policy_from_env(
|
|
65
|
+
profile_class: str = "local",
|
|
66
|
+
mode_override: str | None = None,
|
|
67
|
+
) -> ImagePolicy:
|
|
68
|
+
"""
|
|
69
|
+
Build an ImagePolicy from environment configuration.
|
|
70
|
+
|
|
71
|
+
Mode resolution order: explicit mode_override (CLI flag), then
|
|
72
|
+
CDS_IMAGE_VERIFICATION, then "policy" for production profiles and
|
|
73
|
+
"off" everywhere else. Unknown values fall back to "policy" for
|
|
74
|
+
production profiles (so a typo can never silently disable the policy)
|
|
75
|
+
and "off" elsewhere.
|
|
76
|
+
"""
|
|
77
|
+
env_mode = os.getenv("CDS_IMAGE_VERIFICATION", "").strip().lower()
|
|
78
|
+
mode = mode_override or env_mode or ("policy" if profile_class == "prod" else "off")
|
|
79
|
+
if mode not in ("off", "policy", "full"):
|
|
80
|
+
mode = "policy" if profile_class == "prod" else "off"
|
|
81
|
+
|
|
82
|
+
registries = tuple(
|
|
83
|
+
part.strip()
|
|
84
|
+
for part in os.getenv("CDS_TRUSTED_REGISTRIES", "").split(",")
|
|
85
|
+
if part.strip()
|
|
86
|
+
) or DEFAULT_TRUSTED_REGISTRIES
|
|
87
|
+
|
|
88
|
+
return ImagePolicy(
|
|
89
|
+
mode=mode,
|
|
90
|
+
trusted_registries=registries,
|
|
91
|
+
oidc_issuer=os.getenv("CDS_TRUSTED_OIDC_ISSUER", "").strip()
|
|
92
|
+
or DEFAULT_TRUSTED_OIDC_ISSUER,
|
|
93
|
+
cert_identity_regexp=os.getenv("CDS_TRUSTED_CERT_IDENTITY_REGEXP", "").strip()
|
|
94
|
+
or DEFAULT_CERT_IDENTITY_REGEXP,
|
|
95
|
+
cosign_bin=os.getenv("CDS_COSIGN_BIN", "").strip() or DEFAULT_COSIGN_BIN,
|
|
96
|
+
key_path=os.getenv("CDS_COSIGN_KEY", "").strip() or None,
|
|
97
|
+
require_digest=profile_class == "prod",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def default_fixture_path() -> Path | None:
|
|
102
|
+
"""Resolve the signed-images fixture path from env or the repo checkout."""
|
|
103
|
+
explicit = os.getenv(_FIXTURE_ENV_VAR, "").strip()
|
|
104
|
+
if explicit:
|
|
105
|
+
return Path(explicit)
|
|
106
|
+
candidate = FIXTURE_RELATIVE_PATH
|
|
107
|
+
return candidate if candidate.is_file() else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def collect_compose_images(compose_yaml: str) -> list[tuple[str, str, bool]]:
|
|
111
|
+
"""Return service name, image reference, and local-build status."""
|
|
112
|
+
try:
|
|
113
|
+
compose = yaml.safe_load(compose_yaml) or {}
|
|
114
|
+
except yaml.YAMLError:
|
|
115
|
+
return []
|
|
116
|
+
services = compose.get("services", {}) if isinstance(compose, dict) else {}
|
|
117
|
+
if not isinstance(services, dict):
|
|
118
|
+
return []
|
|
119
|
+
return [
|
|
120
|
+
(
|
|
121
|
+
str(name),
|
|
122
|
+
service["image"],
|
|
123
|
+
service["image"].startswith("local/") and _has_local_build(service),
|
|
124
|
+
)
|
|
125
|
+
for name, service in services.items()
|
|
126
|
+
if isinstance(service, dict) and isinstance(service.get("image"), str)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _has_local_build(service: dict[str, Any]) -> bool:
|
|
131
|
+
build = service.get("build")
|
|
132
|
+
if isinstance(build, str):
|
|
133
|
+
return bool(build.strip())
|
|
134
|
+
if not isinstance(build, dict):
|
|
135
|
+
return False
|
|
136
|
+
return any(
|
|
137
|
+
isinstance(build.get(key), str) and bool(build[key].strip())
|
|
138
|
+
for key in ("context", "dockerfile", "dockerfile_inline")
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _registry_is_trusted(registry: str, policy: ImagePolicy) -> bool:
|
|
143
|
+
trusted_registries = {trusted.casefold() for trusted in policy.trusted_registries}
|
|
144
|
+
return registry.casefold() in trusted_registries
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _finding(
|
|
148
|
+
rule_id: str,
|
|
149
|
+
severity: str,
|
|
150
|
+
service: str,
|
|
151
|
+
image: str,
|
|
152
|
+
message: str,
|
|
153
|
+
recommendation: list[str],
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
return {
|
|
156
|
+
"rule_id": rule_id,
|
|
157
|
+
"severity": severity,
|
|
158
|
+
"module": service,
|
|
159
|
+
"message": message,
|
|
160
|
+
"path": f"services.{service}.image",
|
|
161
|
+
"value": image,
|
|
162
|
+
"recommendation": recommendation,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _static_findings(
|
|
167
|
+
images: list[tuple[str, str, bool]],
|
|
168
|
+
policy: ImagePolicy,
|
|
169
|
+
) -> list[dict[str, Any]]:
|
|
170
|
+
findings: list[dict[str, Any]] = []
|
|
171
|
+
for service, image, is_local_build in images:
|
|
172
|
+
if is_local_build:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
ref = parse_image_reference(image)
|
|
176
|
+
registry = cast(str, ref["registry"])
|
|
177
|
+
if not _registry_is_trusted(registry, policy):
|
|
178
|
+
findings.append(_finding(
|
|
179
|
+
"CDS-SEC-052",
|
|
180
|
+
"medium",
|
|
181
|
+
service,
|
|
182
|
+
image,
|
|
183
|
+
f"Image registry '{ref['registry']}' is not in the trusted registry allowlist",
|
|
184
|
+
[
|
|
185
|
+
"Restrict images to trusted registries.",
|
|
186
|
+
"Maintain an explicit allowlist per environment via CDS_TRUSTED_REGISTRIES.",
|
|
187
|
+
],
|
|
188
|
+
))
|
|
189
|
+
|
|
190
|
+
if "@sha256:" not in image and ref["tag"] == "latest":
|
|
191
|
+
findings.append(_finding(
|
|
192
|
+
"CDS-SEC-050",
|
|
193
|
+
"medium",
|
|
194
|
+
service,
|
|
195
|
+
image,
|
|
196
|
+
"Container image uses the latest tag",
|
|
197
|
+
[
|
|
198
|
+
"Pin images to explicit versions.",
|
|
199
|
+
"Prefer immutable digests for critical services.",
|
|
200
|
+
],
|
|
201
|
+
))
|
|
202
|
+
|
|
203
|
+
if policy.require_digest and "@sha256:" not in image:
|
|
204
|
+
findings.append(_finding(
|
|
205
|
+
"CDS-SEC-051",
|
|
206
|
+
"medium",
|
|
207
|
+
service,
|
|
208
|
+
image,
|
|
209
|
+
"Critical service image is not pinned by digest",
|
|
210
|
+
[
|
|
211
|
+
"Use image digests for critical services.",
|
|
212
|
+
"Define policy exceptions only when necessary.",
|
|
213
|
+
],
|
|
214
|
+
))
|
|
215
|
+
return findings
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _verify_with_cosign(
|
|
219
|
+
image_ref: str,
|
|
220
|
+
policy: ImagePolicy,
|
|
221
|
+
attestation_type: str | None,
|
|
222
|
+
) -> tuple[bool, str]:
|
|
223
|
+
"""Run cosign verify / verify-attestation and return (ok, detail)."""
|
|
224
|
+
cosign_path = shutil.which(policy.cosign_bin)
|
|
225
|
+
if cosign_path is None:
|
|
226
|
+
return False, (
|
|
227
|
+
f"'{policy.cosign_bin}' was not found on PATH; install cosign or "
|
|
228
|
+
"set CDS_COSIGN_BIN"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
command = [cosign_path]
|
|
232
|
+
if attestation_type is not None:
|
|
233
|
+
command += ["verify-attestation", "--type", attestation_type]
|
|
234
|
+
else:
|
|
235
|
+
command += ["verify"]
|
|
236
|
+
if policy.key_path:
|
|
237
|
+
command += ["--key", policy.key_path]
|
|
238
|
+
else:
|
|
239
|
+
command += [
|
|
240
|
+
"--certificate-identity-regexp",
|
|
241
|
+
policy.cert_identity_regexp,
|
|
242
|
+
"--certificate-oidc-issuer",
|
|
243
|
+
policy.oidc_issuer,
|
|
244
|
+
]
|
|
245
|
+
command.append(image_ref)
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
result = subprocess.run(command, capture_output=True, text=True, timeout=120) # nosec B603
|
|
249
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
250
|
+
return False, f"cosign invocation failed: {exc}"
|
|
251
|
+
|
|
252
|
+
if result.returncode != 0:
|
|
253
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
254
|
+
return False, detail or "cosign rejected the image"
|
|
255
|
+
return True, ""
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _load_fixture(path: Path | None) -> tuple[dict[str, Any] | None, str | None]:
|
|
259
|
+
"""
|
|
260
|
+
Load the signed-images fixture.
|
|
261
|
+
|
|
262
|
+
Returns (data, None) on success, (None, error) when a configured fixture
|
|
263
|
+
path could not be loaded, and (None, None) when no fixture was configured
|
|
264
|
+
(callers then fall back to live cosign verification).
|
|
265
|
+
"""
|
|
266
|
+
if path is None:
|
|
267
|
+
return None, None
|
|
268
|
+
try:
|
|
269
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
270
|
+
data = json.load(handle)
|
|
271
|
+
except (OSError, ValueError) as exc:
|
|
272
|
+
return None, f"could not be loaded: {exc}"
|
|
273
|
+
if not isinstance(data, dict):
|
|
274
|
+
return None, "does not contain a JSON object"
|
|
275
|
+
return data, None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _fixture_entry(
|
|
279
|
+
fixture: dict[str, Any] | None,
|
|
280
|
+
image_ref: str,
|
|
281
|
+
) -> dict[str, Any] | None:
|
|
282
|
+
"""Return the fixture entry whose repository matches image_ref, if any."""
|
|
283
|
+
if fixture is None:
|
|
284
|
+
return None
|
|
285
|
+
images = fixture.get("images", {})
|
|
286
|
+
if not isinstance(images, dict):
|
|
287
|
+
return None
|
|
288
|
+
folded_image_ref = image_ref.casefold()
|
|
289
|
+
for entry in images.values():
|
|
290
|
+
if not isinstance(entry, dict):
|
|
291
|
+
continue
|
|
292
|
+
repository = entry.get("repository")
|
|
293
|
+
if not isinstance(repository, str):
|
|
294
|
+
continue
|
|
295
|
+
folded_repository = repository.casefold()
|
|
296
|
+
if folded_image_ref == folded_repository or folded_image_ref.startswith(
|
|
297
|
+
(folded_repository + "@", folded_repository + ":")
|
|
298
|
+
):
|
|
299
|
+
return entry
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _verification_findings(
|
|
304
|
+
images: list[tuple[str, str, bool]],
|
|
305
|
+
policy: ImagePolicy,
|
|
306
|
+
fixture: dict[str, Any] | None,
|
|
307
|
+
) -> list[dict[str, Any]]:
|
|
308
|
+
findings: list[dict[str, Any]] = []
|
|
309
|
+
for service, image, is_local_build in images:
|
|
310
|
+
if is_local_build:
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
entry = _fixture_entry(fixture, image)
|
|
314
|
+
registry = cast(str, parse_image_reference(image)["registry"])
|
|
315
|
+
if entry is not None and _registry_is_trusted(registry, policy):
|
|
316
|
+
ref_digest = image.rsplit("@", 1)[1] if "@sha256:" in image else None
|
|
317
|
+
entry_digest = entry.get("digest")
|
|
318
|
+
if ref_digest is None:
|
|
319
|
+
findings.append(_finding(
|
|
320
|
+
"CDS-VER-003",
|
|
321
|
+
"high",
|
|
322
|
+
service,
|
|
323
|
+
image,
|
|
324
|
+
"Tagged image reference cannot be verified against the signed-images fixture; pin the image by digest",
|
|
325
|
+
[
|
|
326
|
+
"Reference the image by its published digest (@sha256:...).",
|
|
327
|
+
"Refresh tests/fixtures/signed-images.json from the latest publish-images run.",
|
|
328
|
+
],
|
|
329
|
+
))
|
|
330
|
+
continue
|
|
331
|
+
if ref_digest != entry_digest:
|
|
332
|
+
findings.append(_finding(
|
|
333
|
+
"CDS-VER-003",
|
|
334
|
+
"high",
|
|
335
|
+
service,
|
|
336
|
+
image,
|
|
337
|
+
"Image digest does not match the signed-images fixture entry",
|
|
338
|
+
[
|
|
339
|
+
"Pull the image by its published digest.",
|
|
340
|
+
"Refresh tests/fixtures/signed-images.json from the latest publish-images run.",
|
|
341
|
+
],
|
|
342
|
+
))
|
|
343
|
+
continue
|
|
344
|
+
if entry.get("signed") is not True:
|
|
345
|
+
findings.append(_finding(
|
|
346
|
+
"CDS-VER-001",
|
|
347
|
+
"high",
|
|
348
|
+
service,
|
|
349
|
+
image,
|
|
350
|
+
"Image has no verifiable signature in the signed-images fixture",
|
|
351
|
+
[
|
|
352
|
+
"Re-publish the image through publish-images.yml so it is signed.",
|
|
353
|
+
"Verify with cosign verify before deploying.",
|
|
354
|
+
],
|
|
355
|
+
))
|
|
356
|
+
if entry.get("provenanceAttested") is not True:
|
|
357
|
+
findings.append(_finding(
|
|
358
|
+
"CDS-VER-002",
|
|
359
|
+
"high",
|
|
360
|
+
service,
|
|
361
|
+
image,
|
|
362
|
+
"Image has no verifiable build provenance attestation",
|
|
363
|
+
[
|
|
364
|
+
"Re-publish the image so the SLSA provenance attestation is attached.",
|
|
365
|
+
"Verify with cosign verify-attestation --type slsaprovenance.",
|
|
366
|
+
],
|
|
367
|
+
))
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
ok, detail = _verify_with_cosign(image, policy, attestation_type=None)
|
|
371
|
+
if not ok:
|
|
372
|
+
findings.append(_finding(
|
|
373
|
+
"CDS-VER-001",
|
|
374
|
+
"high",
|
|
375
|
+
service,
|
|
376
|
+
image,
|
|
377
|
+
f"Image signature could not be verified: {detail}",
|
|
378
|
+
[
|
|
379
|
+
"Ensure the image was signed by the trusted publish-images workflow.",
|
|
380
|
+
"Provide a known-good signed-images fixture for offline verification.",
|
|
381
|
+
],
|
|
382
|
+
))
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
ok, detail = _verify_with_cosign(image, policy, attestation_type="slsaprovenance")
|
|
386
|
+
if not ok:
|
|
387
|
+
findings.append(_finding(
|
|
388
|
+
"CDS-VER-002",
|
|
389
|
+
"high",
|
|
390
|
+
service,
|
|
391
|
+
image,
|
|
392
|
+
f"Build provenance attestation could not be verified: {detail}",
|
|
393
|
+
[
|
|
394
|
+
"Re-publish the image through publish-images.yml so provenance is attested.",
|
|
395
|
+
"Verify with cosign verify-attestation --type slsaprovenance.",
|
|
396
|
+
],
|
|
397
|
+
))
|
|
398
|
+
return findings
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def verify_images(
|
|
402
|
+
compose_yaml: str,
|
|
403
|
+
policy: ImagePolicy,
|
|
404
|
+
fixture: Path | None = None,
|
|
405
|
+
) -> list[dict[str, Any]]:
|
|
406
|
+
"""
|
|
407
|
+
Run the image policy over a rendered compose file.
|
|
408
|
+
|
|
409
|
+
Returns findings in the same shape as cli.security findings, sorted by
|
|
410
|
+
severity then rule id. Returns [] when the policy mode is "off".
|
|
411
|
+
"""
|
|
412
|
+
if policy.mode == "off":
|
|
413
|
+
return []
|
|
414
|
+
|
|
415
|
+
images = collect_compose_images(compose_yaml)
|
|
416
|
+
findings: list[dict[str, Any]] = _static_findings(images, policy)
|
|
417
|
+
if policy.mode == "full":
|
|
418
|
+
fixture_data, fixture_error = _load_fixture(fixture)
|
|
419
|
+
if fixture_error is not None:
|
|
420
|
+
findings.append(_finding(
|
|
421
|
+
"CDS-VER-004",
|
|
422
|
+
"high",
|
|
423
|
+
"<profile>",
|
|
424
|
+
str(fixture),
|
|
425
|
+
f"Configured signed-images fixture '{fixture}' {fixture_error}; "
|
|
426
|
+
"failing closed instead of silently falling back to live cosign "
|
|
427
|
+
"verification with different trust constraints",
|
|
428
|
+
[
|
|
429
|
+
"Fix the path in CDS_SIGNED_IMAGES_FIXTURE or unset it to use the bundled fixture.",
|
|
430
|
+
"Restore tests/fixtures/signed-images.json if it was removed.",
|
|
431
|
+
],
|
|
432
|
+
))
|
|
433
|
+
else:
|
|
434
|
+
findings.extend(_verification_findings(images, policy, fixture_data))
|
|
435
|
+
|
|
436
|
+
findings.sort(key=lambda x: (
|
|
437
|
+
SEVERITY_ORDER.get(x["severity"], 99),
|
|
438
|
+
x["rule_id"],
|
|
439
|
+
x["path"],
|
|
440
|
+
))
|
|
441
|
+
return findings
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def validate_fixture(fixture: dict[str, Any]) -> list[str]:
|
|
445
|
+
"""
|
|
446
|
+
Structural validation for a signed-images fixture. Returns error strings,
|
|
447
|
+
or an empty list when the fixture is well-formed.
|
|
448
|
+
"""
|
|
449
|
+
errors: list[str] = []
|
|
450
|
+
if fixture.get("schemaVersion") != 1:
|
|
451
|
+
errors.append("schemaVersion must be 1")
|
|
452
|
+
trust_root = fixture.get("trustRoot", {})
|
|
453
|
+
if not isinstance(trust_root, dict):
|
|
454
|
+
errors.append("trustRoot must be an object")
|
|
455
|
+
else:
|
|
456
|
+
for key in ("oidcIssuer", "certificateIdentityRegexp"):
|
|
457
|
+
if not isinstance(trust_root.get(key), str) or not trust_root[key]:
|
|
458
|
+
errors.append(f"trustRoot.{key} must be a non-empty string")
|
|
459
|
+
registries = trust_root.get("registries")
|
|
460
|
+
if not isinstance(registries, list) or not registries:
|
|
461
|
+
errors.append("trustRoot.registries must be a non-empty list")
|
|
462
|
+
|
|
463
|
+
images = fixture.get("images", {})
|
|
464
|
+
if not isinstance(images, dict) or not images:
|
|
465
|
+
errors.append("images must be a non-empty object")
|
|
466
|
+
else:
|
|
467
|
+
for name, entry in images.items():
|
|
468
|
+
if not isinstance(entry, dict):
|
|
469
|
+
errors.append(f"images.{name} must be an object")
|
|
470
|
+
continue
|
|
471
|
+
if not isinstance(entry.get("repository"), str) or not entry["repository"]:
|
|
472
|
+
errors.append(f"images.{name}.repository must be a non-empty string")
|
|
473
|
+
digest = entry.get("digest")
|
|
474
|
+
if not isinstance(digest, str) or not _DIGEST_PATTERN.match(digest):
|
|
475
|
+
errors.append(f"images.{name}.digest must match sha256:<64 hex chars>")
|
|
476
|
+
elif digest == "sha256:" + "0" * 64:
|
|
477
|
+
errors.append(
|
|
478
|
+
f"images.{name}.digest is the all-zero placeholder; copy the real "
|
|
479
|
+
"digest from the latest publish-images run (docs/image-signing.md)"
|
|
480
|
+
)
|
|
481
|
+
for flag in ("signed", "provenanceAttested", "sbomAttested"):
|
|
482
|
+
if not isinstance(entry.get(flag), bool):
|
|
483
|
+
errors.append(f"images.{name}.{flag} must be a boolean")
|
|
484
|
+
return errors
|
cli/loader.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# cli/loader.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from .diagnostics import Diagnostic
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_MODULE_ROOT_MARKERS = {"modules", "modules-experimental"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_yaml_file(path: Path) -> tuple[dict[str, Any] | None, list[Diagnostic]]:
|
|
16
|
+
if not path.exists():
|
|
17
|
+
return None, [
|
|
18
|
+
Diagnostic(
|
|
19
|
+
level="error",
|
|
20
|
+
code="E020",
|
|
21
|
+
message=f"YAML file not found: {path}",
|
|
22
|
+
path=str(path),
|
|
23
|
+
)
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
with path.open("r", encoding="utf-8") as f:
|
|
28
|
+
data = yaml.safe_load(f)
|
|
29
|
+
except UnicodeDecodeError as e:
|
|
30
|
+
return None, [
|
|
31
|
+
Diagnostic(
|
|
32
|
+
level="error",
|
|
33
|
+
code="E001",
|
|
34
|
+
message=f"File is not valid UTF-8 text: {e}",
|
|
35
|
+
path=str(path),
|
|
36
|
+
)
|
|
37
|
+
]
|
|
38
|
+
except yaml.YAMLError as e:
|
|
39
|
+
return None, [
|
|
40
|
+
Diagnostic(
|
|
41
|
+
level="error",
|
|
42
|
+
code="E001",
|
|
43
|
+
message=f"Invalid YAML: {e}",
|
|
44
|
+
path=str(path),
|
|
45
|
+
)
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
return None, [
|
|
50
|
+
Diagnostic(
|
|
51
|
+
level="error",
|
|
52
|
+
code="E010",
|
|
53
|
+
message="Top-level YAML document must be a mapping/object.",
|
|
54
|
+
path=str(path),
|
|
55
|
+
)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
return data, []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_module_file(
|
|
62
|
+
source: str,
|
|
63
|
+
profile_dir: Path,
|
|
64
|
+
module_root: Path | None = None,
|
|
65
|
+
diagnostic_path: str | None = None,
|
|
66
|
+
) -> tuple[Path | None, list[Diagnostic]]:
|
|
67
|
+
source_path = Path(source).expanduser()
|
|
68
|
+
path = diagnostic_path or str(source)
|
|
69
|
+
|
|
70
|
+
if source_path.is_absolute():
|
|
71
|
+
return None, [
|
|
72
|
+
Diagnostic(
|
|
73
|
+
level="error",
|
|
74
|
+
code="E022",
|
|
75
|
+
message=f'Module source "{source}" must be relative to an allowed modules root.',
|
|
76
|
+
path=path,
|
|
77
|
+
)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
if source_path.parts and source_path.parts[0] == ".":
|
|
81
|
+
source_path = source_path.relative_to(".")
|
|
82
|
+
|
|
83
|
+
if module_root is not None:
|
|
84
|
+
allowed_root = module_root.expanduser()
|
|
85
|
+
if allowed_root.is_file():
|
|
86
|
+
allowed_root = allowed_root.parent
|
|
87
|
+
allowed_root = allowed_root.resolve()
|
|
88
|
+
candidate = (allowed_root / source_path / "module.yaml").resolve()
|
|
89
|
+
else:
|
|
90
|
+
allowed_root = _derive_allowed_module_root(profile_dir, source_path)
|
|
91
|
+
if allowed_root is None:
|
|
92
|
+
return None, [
|
|
93
|
+
Diagnostic(
|
|
94
|
+
level="error",
|
|
95
|
+
code="E022",
|
|
96
|
+
message=(
|
|
97
|
+
f'Module source "{source}" must resolve under a "modules/" '
|
|
98
|
+
'or "modules-experimental/" directory.'
|
|
99
|
+
),
|
|
100
|
+
path=path,
|
|
101
|
+
)
|
|
102
|
+
]
|
|
103
|
+
candidate = (profile_dir / source_path / "module.yaml").resolve()
|
|
104
|
+
|
|
105
|
+
if not _is_within(candidate, allowed_root):
|
|
106
|
+
return None, [
|
|
107
|
+
Diagnostic(
|
|
108
|
+
level="error",
|
|
109
|
+
code="E022",
|
|
110
|
+
message=(
|
|
111
|
+
f'Module source "{source}" resolves outside allowed module root '
|
|
112
|
+
f'"{allowed_root}".'
|
|
113
|
+
),
|
|
114
|
+
path=path,
|
|
115
|
+
)
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
return candidate, []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _derive_allowed_module_root(profile_dir: Path, source_path: Path) -> Path | None:
|
|
122
|
+
parts = source_path.parts
|
|
123
|
+
for index, part in enumerate(parts):
|
|
124
|
+
if part in _MODULE_ROOT_MARKERS:
|
|
125
|
+
return (profile_dir / Path(*parts[: index + 1])).resolve()
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_within(candidate: Path, allowed_root: Path) -> bool:
|
|
130
|
+
try:
|
|
131
|
+
candidate.resolve().relative_to(allowed_root.resolve())
|
|
132
|
+
return True
|
|
133
|
+
except ValueError:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resolve_module_dir(
|
|
138
|
+
source: str,
|
|
139
|
+
profile_dir: Path | None,
|
|
140
|
+
module_root: Path | None = None,
|
|
141
|
+
) -> Path | None:
|
|
142
|
+
"""
|
|
143
|
+
Resolve a module's `source` field to its containing directory, enforcing
|
|
144
|
+
the same allowed-root boundary as resolve_module_file().
|
|
145
|
+
|
|
146
|
+
Unlike resolve_module_file(), this has no diagnostics list: callers (the
|
|
147
|
+
renderer, when computing bases for volume/build-context path rewriting)
|
|
148
|
+
already treat a None return as "not a usable base" and silently exclude
|
|
149
|
+
it, so a boundary violation here fails closed the same way a missing
|
|
150
|
+
module directory already does; no new diagnostic plumbing needed.
|
|
151
|
+
"""
|
|
152
|
+
if not isinstance(source, str):
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
source_path = Path(source).expanduser()
|
|
156
|
+
|
|
157
|
+
if source_path.is_absolute():
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
if source_path.parts and source_path.parts[0] == ".":
|
|
161
|
+
source_path = source_path.relative_to(".")
|
|
162
|
+
|
|
163
|
+
if module_root is not None:
|
|
164
|
+
allowed_root = module_root.expanduser()
|
|
165
|
+
if allowed_root.is_file():
|
|
166
|
+
allowed_root = allowed_root.parent
|
|
167
|
+
allowed_root = allowed_root.resolve()
|
|
168
|
+
candidate = (allowed_root / source_path).resolve()
|
|
169
|
+
else:
|
|
170
|
+
if profile_dir is None:
|
|
171
|
+
return None
|
|
172
|
+
allowed_root = _derive_allowed_module_root(profile_dir, source_path)
|
|
173
|
+
if allowed_root is None:
|
|
174
|
+
return None
|
|
175
|
+
candidate = (profile_dir / source_path).resolve()
|
|
176
|
+
|
|
177
|
+
if not _is_within(candidate, allowed_root):
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
return candidate
|