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/security.py ADDED
@@ -0,0 +1,768 @@
1
+ #cli/security.py
2
+ """
3
+ Security validation of composition profiles against a rule set.
4
+ Scans both the profile config values and .env secrets for vulnerabilities.
5
+ ${secrets.*} interpolation references in the profile are intentional and skipped.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ from importlib.resources import files
13
+ from importlib.resources.abc import Traversable
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+ from jsonschema import Draft202012Validator
19
+
20
+ from dataclasses import dataclass
21
+
22
+ from .diagnostics import Diagnostic
23
+ from .loader import load_yaml_file, resolve_module_file
24
+ from .planner import build_plan
25
+ from .renderer import _compose_service_name, render_compose
26
+ from .secrets import load_secrets_from_env
27
+ from .security_common import SECRET_KEY_RE, SEVERITY_ORDER, infer_profile_class
28
+
29
+ _PROFILE_SCOPES = {
30
+ "profile",
31
+ "profile-raw",
32
+ "profile-resolved",
33
+ "module-values",
34
+ "bindings",
35
+ }
36
+
37
+ _ENV_SCOPES = {
38
+ "service-env",
39
+ "service",
40
+ "runtime",
41
+ }
42
+
43
+ # Rules in this scope are matched against the *rendered* Compose service
44
+ # definitions (command/entrypoint/logging), not the profile or .env inputs.
45
+ # This is the only way to see where a module's implementation template
46
+ # actually places a secret-bearing value (e.g. a "${config.x}" reference
47
+ # used inside a "command:" list becomes a Compose-time "${CDS_*}"
48
+ # placeholder that leaks via /proc/<pid>/cmdline once docker compose
49
+ # substitutes it) -- that placement is invisible in the unrendered profile.
50
+ _RENDERED_COMPOSE_SCOPES = {
51
+ "rendered-compose",
52
+ }
53
+
54
+ # Compose service keys where a value is exposed via process listings
55
+ # (command args / entrypoint / healthcheck probe) or captured in logging
56
+ # configuration, as opposed to "environment", which is comparatively
57
+ # better protected.
58
+ _LEAK_PRONE_SERVICE_KEYS = ("command", "entrypoint", "healthcheck", "logging")
59
+ # ---------------------------------------------------------------------------
60
+ # File I/O
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def _load_json(path: Path | Traversable) -> Any:
64
+ with path.open("r", encoding="utf-8") as f:
65
+ return json.load(f)
66
+
67
+
68
+ def _load_yaml(path: Path) -> Any:
69
+ with path.open("r", encoding="utf-8") as f:
70
+ return yaml.safe_load(f)
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Rule set loading
75
+ # ---------------------------------------------------------------------------
76
+
77
+ def _validate_rule_set(
78
+ rule_schema_path: Path | Traversable | None = None,
79
+ rule_set_path: Path | Traversable | None = None,
80
+ ) -> dict[str, Any]:
81
+ resources = files("cli.resources")
82
+ rule_schema_path = rule_schema_path or resources.joinpath("rule-schema.json")
83
+ rule_set_path = rule_set_path or resources.joinpath("rule-set.json")
84
+ schema = _load_json(rule_schema_path)
85
+ rule_set = _load_json(rule_set_path)
86
+ validator = Draft202012Validator(schema)
87
+ errors = sorted(validator.iter_errors(rule_set), key=lambda e: list(e.path))
88
+ if errors:
89
+ msgs = [
90
+ f'{".".join(str(x) for x in err.path) or "<root>"}: {err.message}'
91
+ for err in errors
92
+ ]
93
+ raise ValueError("Rule-set validation failed:\n - " + "\n - ".join(msgs))
94
+ return rule_set
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Flattening
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def _flatten(obj: Any, prefix: str = "") -> list[tuple[str, Any]]:
102
+ """Recursively flatten a nested dict/list into (path, value) pairs."""
103
+ items: list[tuple[str, Any]] = []
104
+ if isinstance(obj, dict):
105
+ for k, v in obj.items():
106
+ path = f"{prefix}.{k}" if prefix else str(k)
107
+ items.extend(_flatten(v, path))
108
+ elif isinstance(obj, list):
109
+ for i, v in enumerate(obj):
110
+ items.extend(_flatten(v, f"{prefix}[{i}]"))
111
+ else:
112
+ items.append((prefix, obj))
113
+ return items
114
+
115
+
116
+ def _flatten_profile_by_module(
117
+ profile: dict[str, Any],
118
+ profile_dir: Path | None = None,
119
+ ) -> list[tuple[str, str, Any]]:
120
+ """
121
+ Returns (module_id, path, value) triples from the profile.
122
+
123
+ - Per-module config is emitted under the module's id.
124
+ - Top-level and spec-level keys outside modules are emitted as "<profile>".
125
+ - Disabled modules are skipped.
126
+ - ${secrets.*} references are left in place here; filtered in rule_matches.
127
+ - If profile_dir is given, each module's own module.yaml is resolved and
128
+ its metadata.productionSuitable (when explicitly false) is exposed as
129
+ a synthetic "_module.productionSuitable" entry, so CDS-SEC-073 can
130
+ flag a non-local profile using a module that isn't production-suitable.
131
+ Resolution failures are skipped silently here; cli/planner.py already
132
+ reports them as validation diagnostics.
133
+ """
134
+ results: list[tuple[str, str, Any]] = []
135
+ spec = profile.get("spec", {})
136
+ modules = spec.get("modules", [])
137
+
138
+ for module_instance in modules:
139
+ if module_instance.get("enabled", False) is False:
140
+ continue
141
+ module_id = module_instance.get("id", "<unknown>")
142
+ for path, value in _flatten(module_instance.get("config", {})):
143
+ results.append((module_id, path, value))
144
+
145
+ if profile_dir is not None and "source" in module_instance:
146
+ module_root = os.getenv("CDS_MODULE_PATH")
147
+ module_root_path = Path(module_root) if module_root else None
148
+ module_file, _diags = resolve_module_file(
149
+ source=module_instance["source"],
150
+ profile_dir=profile_dir,
151
+ module_root=module_root_path,
152
+ )
153
+ if module_file is not None:
154
+ module_def, _diags = load_yaml_file(module_file)
155
+ if module_def is not None:
156
+ production_suitable = module_def.get("metadata", {}).get(
157
+ "productionSuitable", True
158
+ )
159
+ if production_suitable is False:
160
+ results.append((module_id, "_module.productionSuitable", False))
161
+
162
+ for key, value in profile.items():
163
+ if key == "spec":
164
+ for spec_key, spec_value in spec.items():
165
+ if spec_key == "modules":
166
+ continue
167
+ for path, v in _flatten(spec_value, spec_key):
168
+ results.append(("<profile>", path, v))
169
+ else:
170
+ for path, v in _flatten(value, key):
171
+ results.append(("<profile>", path, v))
172
+
173
+ return results
174
+
175
+
176
+ def _flatten_env_secrets(secrets: dict[str, str]) -> list[tuple[str, str, Any]]:
177
+ """
178
+ Emit .env secrets as flat items attributed to "<env>".
179
+ Scanned directly by the rule engine for vulnerabilities in secret values.
180
+ """
181
+ return [("<env>", f"secrets.{key}", value) for key, value in secrets.items()]
182
+
183
+
184
+ def _normalize_scan_path(path: Path) -> str:
185
+ resolved = path.resolve()
186
+ try:
187
+ return resolved.relative_to(Path.cwd().resolve()).as_posix()
188
+ except ValueError:
189
+ return resolved.as_posix()
190
+
191
+
192
+ def _flatten_env_inputs(
193
+ secrets: dict[str, str],
194
+ env_file: str | None,
195
+ ) -> list[tuple[str, str, Any]]:
196
+ """
197
+ Emit both loaded .env secrets and the env file path itself when present.
198
+
199
+ Some rules evaluate how a secret-bearing env file is located or managed
200
+ rather than inspecting individual secret values. Represent the file path as
201
+ a synthetic flat item so those rules can use the same matcher.
202
+ """
203
+ items = _flatten_env_secrets(secrets)
204
+ env_path = Path(env_file) if env_file is not None else Path(".env")
205
+ if env_path.exists():
206
+ scan_path = _normalize_scan_path(env_path)
207
+ items.append(("<env>", scan_path, scan_path))
208
+ return items
209
+
210
+
211
+ def _flatten_rendered_leak_surfaces(
212
+ compose: dict[str, Any] | None,
213
+ service_to_module: dict[str, str] | None = None,
214
+ ) -> list[tuple[str, str, Any]]:
215
+ """
216
+ Flatten only the leak-prone parts of a rendered Compose document:
217
+ each service's "command", "entrypoint", "healthcheck", and "logging"
218
+ fields.
219
+
220
+ Unlike the profile flattener, this operates on the fully rendered
221
+ Compose model, so "${config.x}" module template references have
222
+ already been resolved to their final "${CDS_*}" Compose-time
223
+ placeholders (or literal values) -- the actual shape a rule needs to
224
+ inspect to tell whether a secret-bearing value ends up somewhere that
225
+ leaks via process listings (command/entrypoint/healthcheck) or log
226
+ configuration, rather than the module.yaml source or profile config
227
+ that produced it.
228
+
229
+ `service_to_module` maps a rendered Compose service name (e.g.
230
+ "vault-vault") back to the profile module id that produced it (e.g.
231
+ "vault"), so findings attribute the same "module" identity other rules
232
+ use. Compose service names are namespaced by the renderer
233
+ (`_compose_service_name`) and don't always equal the module id; when no
234
+ mapping is supplied (or a service name has none), the raw Compose
235
+ service name is used as a documented fallback.
236
+ """
237
+ if not isinstance(compose, dict):
238
+ return []
239
+
240
+ services = compose.get("services", {})
241
+ if not isinstance(services, dict):
242
+ return []
243
+
244
+ service_to_module = service_to_module or {}
245
+ results: list[tuple[str, str, Any]] = []
246
+ for service_name, service_def in services.items():
247
+ if not isinstance(service_def, dict):
248
+ continue
249
+ module_id = service_to_module.get(service_name, service_name)
250
+ for key in _LEAK_PRONE_SERVICE_KEYS:
251
+ if key not in service_def:
252
+ continue
253
+ base_path = f"services.{service_name}.{key}"
254
+ for path, value in _flatten(service_def[key], base_path):
255
+ results.append((module_id, path, value))
256
+ return results
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Secret reference detection
261
+ # ---------------------------------------------------------------------------
262
+
263
+ _SECRET_REF_RE = re.compile(
264
+ r"^\$\{secrets\.[^}]+\}$" # ${secrets.KEY}
265
+ r"|^secrets\.[A-Za-z0-9_.]+$" # secrets.KEY
266
+ )
267
+
268
+ def _is_secret_reference(value: Any) -> bool:
269
+ """
270
+ Returns True if value is an unresolved ${secrets.*} interpolation.
271
+ These are intentional references to .env values, not real config values,
272
+ so they must be excluded from rule evaluation to avoid false positives.
273
+ """
274
+ return isinstance(value, str) and bool(_SECRET_REF_RE.match(value))
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Profile class inference
279
+ # ---------------------------------------------------------------------------
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Helpers
283
+ # ---------------------------------------------------------------------------
284
+
285
+
286
+ def _entropy_like(value: str) -> bool:
287
+ if not isinstance(value, str) or len(value) < 16:
288
+ return False
289
+ classes = (
290
+ bool(re.search(r"[a-z]", value))
291
+ + bool(re.search(r"[A-Z]", value))
292
+ + bool(re.search(r"\d", value))
293
+ + bool(re.search(r"[^A-Za-z0-9]", value))
294
+ )
295
+ return classes >= 3
296
+
297
+
298
+ def _service_type_for_path(path: str) -> str:
299
+ p = path.lower()
300
+ if "superset" in p or "dagster-webserver" in p or "ui" in p:
301
+ return "admin-ui"
302
+ if "postgres" in p or "mysql" in p or "db" in p:
303
+ return "database"
304
+ return "generic"
305
+
306
+
307
+ def _path_pattern_to_regex(pattern: str) -> str:
308
+ return "^" + re.escape(pattern).replace(r"\*", ".*") + "$"
309
+
310
+
311
+ def _path_matches_any(path: str, patterns: list[str]) -> bool:
312
+ if not patterns:
313
+ return True
314
+ return any(re.match(_path_pattern_to_regex(p), path) for p in patterns)
315
+
316
+
317
+ def _redact(value: Any) -> str | None:
318
+ if value is None:
319
+ return None
320
+ sval = str(value)
321
+ if len(sval) <= 6:
322
+ return "***"
323
+ return sval[:2] + "***REDACTED***" + sval[-2:]
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Condition evaluation
328
+ # ---------------------------------------------------------------------------
329
+ _NON_SECRET_PATH_SUFFIXES = (
330
+ "description",
331
+ "name",
332
+ "label",
333
+ "title",
334
+ "comment",
335
+ "notes",
336
+ )
337
+ def _eval_condition(
338
+ path: str,
339
+ key: str,
340
+ value: Any,
341
+ cond: dict[str, Any],
342
+ profile_class: str,
343
+ ) -> bool:
344
+ sval = "" if value is None else str(value)
345
+
346
+ # Never flag known metadata fields as secret-like
347
+ if cond.get("entropy") == "high" and key.lower().endswith(_NON_SECRET_PATH_SUFFIXES):
348
+ return False
349
+
350
+ if "pathPatterns" in cond and not _path_matches_any(path, cond["pathPatterns"]):
351
+ return False
352
+ if "keyRegex" in cond and not re.search(cond["keyRegex"], key or ""):
353
+ return False
354
+ if "valueRegex" in cond and not re.search(cond["valueRegex"], sval):
355
+ return False
356
+ if "notValueRegex" in cond and re.search(cond["notValueRegex"], sval):
357
+ return False
358
+ if "containsAny" in cond and not any(x in sval for x in cond["containsAny"]):
359
+ return False
360
+ if "equalsAny" in cond and sval not in cond["equalsAny"]:
361
+ return False
362
+ if "profileClasses" in cond and profile_class not in cond["profileClasses"]:
363
+ return False
364
+ if cond.get("envInterpolation") is True and "${" not in sval:
365
+ return False
366
+ if cond.get("allowEmpty") is True and sval not in ("", "None", "null"):
367
+ return False
368
+ if cond.get("entropy") == "high" and not _entropy_like(sval):
369
+ return False
370
+ if "minLength" in cond and len(sval) < cond["minLength"]:
371
+ return False
372
+ if "serviceTypes" in cond and _service_type_for_path(path) not in cond["serviceTypes"]:
373
+ return False
374
+
375
+ if "portExposure" in cond:
376
+ exposure = cond["portExposure"]
377
+ # Comparing a scanned config's string, not binding to an interface.
378
+ if exposure == "0.0.0.0" and "0.0.0.0:" not in sval: # nosec B104
379
+ return False
380
+ if exposure == "host-published" and ":" not in sval:
381
+ return False
382
+ if exposure == "localhost-only" and not (
383
+ sval.startswith("127.0.0.1:") or sval.startswith("localhost:")
384
+ ):
385
+ return False
386
+
387
+ if "imageTagPolicy" in cond:
388
+ policy = cond["imageTagPolicy"]
389
+ if policy == "forbid-latest" and not sval.endswith(":latest"):
390
+ return False
391
+ if policy == "require-digest" and "@sha256:" in sval:
392
+ return False
393
+ if policy == "require-tag" and (":" in sval or "@sha256:" in sval):
394
+ return False
395
+
396
+ if "runtimeFlags" in cond and not any(flag in sval for flag in cond["runtimeFlags"]):
397
+ return False
398
+ if "fallbackPattern" in cond and not re.search(cond["fallbackPattern"], sval):
399
+ return False
400
+
401
+ if "secretSinkPolicy" in cond:
402
+ forbidden_segments = [
403
+ ".labels.",
404
+ ".annotations.",
405
+ ".command",
406
+ ".args.",
407
+ "outputs.",
408
+ "plan.preview.",
409
+ ]
410
+ is_forbidden_sink = any(seg in path for seg in forbidden_segments)
411
+ if cond["secretSinkPolicy"] == "forbidden" and not is_forbidden_sink:
412
+ return False
413
+
414
+ return True
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Cross-item checks (cannot be expressed as per-item rules)
418
+ # ---------------------------------------------------------------------------
419
+
420
+ def _check_secret_reuse(
421
+ flat_items: list[tuple[str, str, Any]],
422
+ ) -> list[dict[str, Any]]:
423
+ """
424
+ Detect the same secret value appearing under different keys.
425
+ Ignores empty values and non-string values.
426
+ """
427
+
428
+ # Collect all (path, value) pairs that look like secrets
429
+ value_to_locations: dict[str, list[tuple[str, str]]] = {}
430
+ for module_id, path, value in flat_items:
431
+ if not isinstance(value, str) or not value:
432
+ continue
433
+ if not SECRET_KEY_RE.search(path.split(".")[-1]):
434
+ continue
435
+ value_to_locations.setdefault(value, []).append((module_id, path))
436
+
437
+ findings = []
438
+ for value, locations in value_to_locations.items():
439
+ if len(locations) < 2:
440
+ continue
441
+ for module_id, path in locations:
442
+ findings.append({
443
+ "rule_id": "CDS-SEC-013",
444
+ "severity": "medium",
445
+ "module": module_id,
446
+ "message": "The same secret appears reused across multiple services",
447
+ "path": path,
448
+ "value": _redact(value), # always redact reuse findings
449
+ "recommendation": [
450
+ "Use separate credentials or secrets per service.",
451
+ "Generate scoped secrets rather than sharing one across components.",
452
+ ],
453
+ })
454
+
455
+ return findings
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # Rule matching
459
+ # ---------------------------------------------------------------------------
460
+
461
+ def _rule_matches(
462
+ rule: dict[str, Any],
463
+ flat_items: list[tuple[str, str, Any]],
464
+ profile_class: str,
465
+ redact_values: bool = False,
466
+ ) -> list[dict[str, Any]]:
467
+
468
+ findings: list[dict[str, Any]] = []
469
+ match = rule["match"]
470
+
471
+ for module_id, path, value in flat_items:
472
+ # Skip unresolved ${secrets.*} references in profile config.
473
+ # They are intentional indirections, not real values.
474
+ if _is_secret_reference(value):
475
+ continue
476
+
477
+ key = path.split(".")[-1] if path else ""
478
+
479
+ if "all" in match:
480
+ ok = all(
481
+ _eval_condition(path, key, value, cond, profile_class)
482
+ for cond in match["all"]
483
+ )
484
+ else:
485
+ ok = any(
486
+ _eval_condition(path, key, value, cond, profile_class)
487
+ for cond in match["any"]
488
+ )
489
+
490
+ if ok:
491
+ findings.append({
492
+ "rule_id": rule["id"],
493
+ "severity": rule["severity"],
494
+ "module": module_id,
495
+ "message": rule["message"],
496
+ "path": path,
497
+ "value": _redact(value) if redact_values else value,
498
+ "recommendation": rule["recommendation"],
499
+ })
500
+
501
+ return findings
502
+
503
+
504
+ def _map_service_to_module(plan: dict[str, Any] | None) -> dict[str, str]:
505
+ """
506
+ Build a rendered-Compose-service-name -> module-id map from a plan.
507
+
508
+ The renderer namespaces each module's Compose service keys via
509
+ `_compose_service_name(module_id, service_name)` (e.g. module "vault"'s
510
+ "vault" service key becomes the rendered "vault-vault" service name),
511
+ so a rendered service name doesn't always equal its owning module id.
512
+ Findings should attribute the same module identity every other rule
513
+ uses, so this recomputes the same namespacing the renderer applies
514
+ (reusing its private helper directly, rather than re-implementing the
515
+ naming rule and risking drift) against each module's pre-render
516
+ Compose service keys from the plan.
517
+ """
518
+ if not isinstance(plan, dict):
519
+ return {}
520
+
521
+ mapping: dict[str, str] = {}
522
+ for module in plan.get("modules", []):
523
+ if not isinstance(module, dict):
524
+ continue
525
+ module_id = module.get("id")
526
+ if not module_id:
527
+ continue
528
+ compose_services = (
529
+ module.get("implementation", {}).get("compose", {}).get("services", {})
530
+ )
531
+ if not isinstance(compose_services, dict):
532
+ continue
533
+ for service_name in compose_services:
534
+ mapping[_compose_service_name(module_id, service_name)] = module_id
535
+ return mapping
536
+
537
+
538
+ @dataclass(frozen=True)
539
+ class PrecomputedRender:
540
+ """
541
+ Precomputed plan/render state a caller can hand to the security scan so
542
+ it doesn't redundantly plan/render the same profile a second time.
543
+
544
+ Replaces a three-argument `plan`/`rendered_compose_yaml`/
545
+ `skip_self_plan_render` matrix (where "is None okay?" depended on
546
+ combinations of the three) with a single object with two clear states:
547
+ - `PrecomputedRender(plan=..., rendered_compose_yaml=...)`: the caller
548
+ already has a successful plan and/or rendered Compose YAML to reuse.
549
+ - `PrecomputedRender(failed=True)`: the caller already tried to plan
550
+ and/or render the profile itself and it failed, so the scan
551
+ shouldn't retry the same failing work.
552
+ When no `PrecomputedRender` is passed at all, the scan does its own
553
+ best-effort plan+render.
554
+ """
555
+
556
+ plan: dict[str, Any] | None = None
557
+ rendered_compose_yaml: str | None = None
558
+ failed: bool = False
559
+
560
+
561
+ def _try_render_compose_for_scan(
562
+ profile_path: Path,
563
+ env_file: str | None,
564
+ environment: str | None,
565
+ precomputed: PrecomputedRender | None = None,
566
+ ) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]:
567
+ """
568
+ Resolve the rendered Compose document (and its service->module map) used
569
+ by "rendered-compose"-scoped rules.
570
+
571
+ Callers that already planned and/or rendered the profile for their own
572
+ purposes (e.g. `cds test`, which runs its own "plan"/"render" stages
573
+ right after security validation) can pass a `PrecomputedRender` in
574
+ directly, so this doesn't redundantly plan and render the same profile
575
+ a second time. When `precomputed` is None, this does a best-effort
576
+ plan + render itself -- unless `precomputed.failed` is set, which tells
577
+ this function that the caller already tried to plan/render the profile
578
+ itself and it failed, so retrying here would just repeat the same
579
+ failure for no benefit (e.g. `cds test`'s own "plan"/"render" stages
580
+ already planned/rendered and reported the failure with full
581
+ diagnostics before calling this).
582
+
583
+ A profile that fails to plan or render is not itself a bug in this
584
+ scan -- those failures are already surfaced with full diagnostics by
585
+ the separate "plan"/"render" stages in `cds test` (or by the caller
586
+ that passed in its own plan/render results), so that expected case
587
+ returns `(None, {}, [])` quietly. Only a genuinely unexpected internal
588
+ error (not a normal plan/render diagnostic) is worth a warning: it
589
+ means the rendered-compose checks silently produced zero findings for
590
+ a reason nobody surfaced, which is exactly the kind of silent gap
591
+ #297 was about.
592
+ """
593
+ diagnostics: list[Diagnostic] = []
594
+ precomputed = precomputed or PrecomputedRender()
595
+ plan = precomputed.plan
596
+ rendered_compose_yaml = precomputed.rendered_compose_yaml
597
+ if precomputed.failed and rendered_compose_yaml is None:
598
+ return None, {}, diagnostics
599
+ try:
600
+ if rendered_compose_yaml is None:
601
+ if plan is None:
602
+ plan, plan_diags = build_plan(
603
+ str(profile_path), env_file=env_file, environment=environment,
604
+ )
605
+ plan_errors = [d for d in plan_diags if d.level == "error"]
606
+ if plan is None or plan_errors:
607
+ first_code = plan_errors[0].code if plan_errors else "unknown"
608
+ diagnostics.append(Diagnostic(
609
+ level="warning",
610
+ code="W096",
611
+ message=(
612
+ "Rendered-compose security checks (e.g. CDS-SEC-070) "
613
+ "were skipped because the profile could not be "
614
+ f"planned ({first_code}); run 'cds plan' for details."
615
+ ),
616
+ path="spec.modules",
617
+ ))
618
+ return None, {}, diagnostics
619
+
620
+ rendered_compose_yaml, render_diags = render_compose(plan, env_file=env_file)
621
+ render_errors = [d for d in render_diags if d.level == "error"]
622
+ if render_errors:
623
+ first_code = render_errors[0].code
624
+ diagnostics.append(Diagnostic(
625
+ level="warning",
626
+ code="W096",
627
+ message=(
628
+ "Rendered-compose security checks (e.g. CDS-SEC-070) "
629
+ "were skipped because the profile could not be "
630
+ f"rendered ({first_code}); run 'cds render' for details."
631
+ ),
632
+ path="spec.modules",
633
+ ))
634
+ return None, {}, diagnostics
635
+
636
+ rendered = yaml.safe_load(rendered_compose_yaml)
637
+ service_to_module = _map_service_to_module(plan)
638
+ return (rendered if isinstance(rendered, dict) else None), service_to_module, diagnostics
639
+ except Exception as exc:
640
+ diagnostics.append(Diagnostic(
641
+ level="warning",
642
+ code="W096",
643
+ message=(
644
+ "Rendered-compose security checks (e.g. CDS-SEC-070) were "
645
+ f"skipped due to an unexpected error: {exc!r}"
646
+ ),
647
+ path="spec.modules",
648
+ ))
649
+ return None, {}, diagnostics
650
+
651
+
652
+ # ---------------------------------------------------------------------------
653
+ # Public entry point
654
+ # ---------------------------------------------------------------------------
655
+
656
+ def run_security_validation(
657
+ profile_path: Path,
658
+ rule_schema_path: Path | Traversable | None = None,
659
+ rule_set_path: Path | Traversable | None = None,
660
+ env_file: str | None = None,
661
+ redact_values: bool = False,
662
+ environment: str | None = None,
663
+ precomputed_render: PrecomputedRender | None = None,
664
+ ) -> tuple[list[dict[str, Any]], list[Diagnostic]]:
665
+ """
666
+ Validate a profile and its .env secrets against the rule set.
667
+
668
+ Two sources are scanned:
669
+ - Profile config values (${secrets.*} references are skipped — they are
670
+ intentional indirections, not real values).
671
+ - .env secret values (scanned directly for weak/leaked secrets).
672
+
673
+ Args:
674
+ profile_path: Path to the profile YAML.
675
+ rule_schema_path: Optional custom rule set JSON schema path.
676
+ rule_set_path: Optional custom rule set JSON path.
677
+ env_file: Optional path to .env file. Defaults to .env in cwd.
678
+ redact_values: If True, secret-like values are redacted in findings.
679
+ environment: Optional environment overlay name. When set, the
680
+ profile's declared metadata.environment (and therefore the
681
+ production security policy applied below) reflects the overlay,
682
+ not just the base profile.
683
+ precomputed_render: Optional `PrecomputedRender` used by
684
+ "rendered-compose"-scoped rules (e.g. CDS-SEC-070). Callers that
685
+ already planned and/or rendered the profile for their own
686
+ purposes (e.g. `cds test`) should pass their plan/rendered
687
+ Compose YAML in via `PrecomputedRender(plan=..., rendered_compose_yaml=...)`
688
+ to avoid planning/rendering the profile again here, or
689
+ `PrecomputedRender(failed=True)` if they already tried and it
690
+ failed, so this doesn't repeat the same failing work. When
691
+ omitted, this does its own best-effort plan+render.
692
+
693
+ Returns:
694
+ Tuple of (findings, diagnostics). Findings are sorted by severity,
695
+ then rule_id, module, and path.
696
+ """
697
+ if environment is not None:
698
+ # Local import: cli.overlay imports cli.validator, not cli.security,
699
+ # so this doesn't introduce a cycle, but keep it scoped/consistent
700
+ # with the other call sites that gained overlay support.
701
+ from .overlay import resolve_profile
702
+
703
+ profile, _, overlay_diags = resolve_profile(str(profile_path), environment)
704
+ if profile is None:
705
+ return [], overlay_diags
706
+ else:
707
+ profile = _load_yaml(profile_path)
708
+ overlay_diags = []
709
+ rule_set = _validate_rule_set(rule_schema_path, rule_set_path)
710
+
711
+ profile_class = infer_profile_class(profile)
712
+
713
+ secrets, secret_diags = load_secrets_from_env(env_file)
714
+
715
+ flat_profile = _flatten_profile_by_module(profile, profile_dir=profile_path.parent)
716
+ flat_env = _flatten_env_inputs(secrets, env_file)
717
+
718
+ # Planning and rendering the profile is only useful when some enabled
719
+ # rule actually declares the "rendered-compose" scope -- e.g. a custom
720
+ # rule set may omit CDS-SEC-070 entirely, in which case doing a full
721
+ # plan+render here would be wasted work on every security scan.
722
+ needs_rendered_compose = any(
723
+ rule.get("enabled", True) and set(rule.get("scope", [])) & _RENDERED_COMPOSE_SCOPES
724
+ for rule in rule_set["rules"]
725
+ )
726
+ if needs_rendered_compose:
727
+ rendered_compose, service_to_module, render_scan_diags = _try_render_compose_for_scan(
728
+ profile_path, env_file, environment,
729
+ precomputed=precomputed_render,
730
+ )
731
+ else:
732
+ rendered_compose, service_to_module, render_scan_diags = None, {}, []
733
+ flat_rendered = _flatten_rendered_leak_surfaces(rendered_compose, service_to_module)
734
+
735
+ findings: list[dict[str, Any]] = []
736
+ for rule in rule_set["rules"]:
737
+ if not rule.get("enabled", True):
738
+ continue
739
+
740
+ rule_scopes = set(rule.get("scope", []))
741
+ if rule_scopes & _PROFILE_SCOPES:
742
+ findings.extend(_rule_matches(
743
+ rule, flat_profile, profile_class,
744
+ redact_values=redact_values,
745
+ ))
746
+
747
+ if rule_scopes & _ENV_SCOPES:
748
+ findings.extend(_rule_matches(
749
+ rule, flat_env, profile_class,
750
+ redact_values=redact_values,
751
+ ))
752
+
753
+ if rule_scopes & _RENDERED_COMPOSE_SCOPES:
754
+ findings.extend(_rule_matches(
755
+ rule, flat_rendered, profile_class,
756
+ redact_values=redact_values,
757
+ ))
758
+
759
+ findings.extend(_check_secret_reuse(flat_profile + flat_env))
760
+
761
+ findings.sort(key=lambda x: (
762
+ SEVERITY_ORDER.get(x["severity"], 99),
763
+ x["rule_id"],
764
+ x["module"],
765
+ x["path"],
766
+ ))
767
+
768
+ return findings, overlay_diags + secret_diags + render_scan_diags