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