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.
@@ -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