composable-data-stack 0.12.0__py3-none-any.whl → 0.13.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/getter.py +1 -1
- cli/k8s_renderer.py +13 -1
- cli/main.py +82 -5
- cli/preflight.py +148 -7
- cli/renderer.py +18 -5
- cli/resources/rule-schema.json +1 -0
- cli/resources/rule-set.json +14 -11
- cli/secret_leak.py +210 -0
- cli/security.py +137 -1
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.13.0.dist-info}/METADATA +2 -2
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.13.0.dist-info}/RECORD +15 -14
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.13.0.dist-info}/WHEEL +1 -1
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.13.0.dist-info}/entry_points.txt +0 -0
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.13.0.dist-info}/licenses/LICENSE +0 -0
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.13.0.dist-info}/top_level.txt +0 -0
cli/getter.py
CHANGED
|
@@ -664,7 +664,7 @@ def _extract_dockerfile_instruction_sources(
|
|
|
664
664
|
continue
|
|
665
665
|
if _contains_template(source):
|
|
666
666
|
continue
|
|
667
|
-
if Path(source).is_absolute():
|
|
667
|
+
if Path(source).is_absolute() or source.startswith(("/", "\\")):
|
|
668
668
|
continue
|
|
669
669
|
matches = list(context_path.glob(source))
|
|
670
670
|
if not matches and "*" not in source and "?" not in source and "[" not in source:
|
cli/k8s_renderer.py
CHANGED
|
@@ -38,6 +38,11 @@ from .renderer import (
|
|
|
38
38
|
_resolve_project_root,
|
|
39
39
|
_substitute_values,
|
|
40
40
|
)
|
|
41
|
+
from .secret_leak import (
|
|
42
|
+
check_generated_text,
|
|
43
|
+
declared_secret_env_names,
|
|
44
|
+
resolved_secret_values,
|
|
45
|
+
)
|
|
41
46
|
from .utils import _atomic_write
|
|
42
47
|
|
|
43
48
|
CHART_API_VERSION = "v2"
|
|
@@ -73,12 +78,14 @@ class K8sRenderError(Exception):
|
|
|
73
78
|
def render_helm(
|
|
74
79
|
plan: dict[str, Any],
|
|
75
80
|
output_dir: str | None = None,
|
|
81
|
+
env_file: str | None = None,
|
|
76
82
|
) -> tuple[dict[str, str], list[Diagnostic]]:
|
|
77
83
|
"""
|
|
78
84
|
Render a Helm chart from a resolved plan.
|
|
79
85
|
|
|
80
86
|
Returns a mapping of chart-relative path -> file contents, plus diagnostics.
|
|
81
|
-
Files are written to `output_dir` when given
|
|
87
|
+
Files are written to `output_dir` when given, unless an error was found
|
|
88
|
+
(including a resolved secret value in any chart file, CDS-SEC-030).
|
|
82
89
|
"""
|
|
83
90
|
diagnostics: list[Diagnostic] = []
|
|
84
91
|
metadata = plan.get("metadata", {})
|
|
@@ -183,6 +190,11 @@ def render_helm(
|
|
|
183
190
|
|
|
184
191
|
diagnostics.extend(_check_unresolved(files))
|
|
185
192
|
diagnostics.extend(_check_secret_references(files, secret_keys))
|
|
193
|
+
secret_values = resolved_secret_values(declared_secret_env_names(plan), env_file)
|
|
194
|
+
for relative_path, content in files.items():
|
|
195
|
+
diagnostics.extend(check_generated_text(
|
|
196
|
+
content, f"{output_dir or 'chart'}/{relative_path}", secret_values
|
|
197
|
+
))
|
|
186
198
|
|
|
187
199
|
if output_dir and not any(
|
|
188
200
|
diagnostic.level == "error" for diagnostic in diagnostics
|
cli/main.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
|
+
import contextlib
|
|
6
|
+
import io
|
|
5
7
|
import json
|
|
6
8
|
import logging
|
|
7
9
|
import os
|
|
@@ -48,6 +50,11 @@ from .planner import build_plan
|
|
|
48
50
|
from .preflight import preflight_passed, run_preflight
|
|
49
51
|
from .renderer import render_compose
|
|
50
52
|
from .report import build_compliance_report
|
|
53
|
+
from .secret_leak import (
|
|
54
|
+
CLI_OUTPUT_REDACTED_CODE,
|
|
55
|
+
RedactingStream,
|
|
56
|
+
resolved_secret_values,
|
|
57
|
+
)
|
|
51
58
|
from .security import PrecomputedRender, run_security_validation
|
|
52
59
|
from .security_common import (
|
|
53
60
|
COMPLIANCE_CATEGORIES,
|
|
@@ -1016,6 +1023,10 @@ def _write_env_file(
|
|
|
1016
1023
|
)
|
|
1017
1024
|
lines.append("")
|
|
1018
1025
|
_atomic_write(output_path, "\n".join(lines))
|
|
1026
|
+
# Holds secrets: keep it owner-only even if _atomic_write's temp-file
|
|
1027
|
+
# creation ever changes (CDS-SEC-032). No-op semantics on Windows.
|
|
1028
|
+
if os.name != "nt":
|
|
1029
|
+
output_path.chmod(0o600)
|
|
1019
1030
|
|
|
1020
1031
|
|
|
1021
1032
|
def _cds_version() -> str:
|
|
@@ -1320,7 +1331,7 @@ def _run_image_verification(
|
|
|
1320
1331
|
return [_unverifiable_image_finding(f"Image verification failed unexpectedly: {e}")]
|
|
1321
1332
|
|
|
1322
1333
|
|
|
1323
|
-
def _render_helm_chart(plan, output_dir, force):
|
|
1334
|
+
def _render_helm_chart(plan, output_dir, force, env_file=None):
|
|
1324
1335
|
"""
|
|
1325
1336
|
Render the Helm chart for a plan and report where it landed.
|
|
1326
1337
|
|
|
@@ -1341,7 +1352,7 @@ def _render_helm_chart(plan, output_dir, force):
|
|
|
1341
1352
|
"like a CDS-generated chart. Pass --force to overwrite it."
|
|
1342
1353
|
)
|
|
1343
1354
|
return 1, []
|
|
1344
|
-
files, diags = render_helm(plan, output_dir=str(target_dir))
|
|
1355
|
+
files, diags = render_helm(plan, output_dir=str(target_dir), env_file=env_file)
|
|
1345
1356
|
return 0, diags
|
|
1346
1357
|
|
|
1347
1358
|
|
|
@@ -1366,10 +1377,74 @@ def _maybe_notify_update() -> None:
|
|
|
1366
1377
|
_LOGGER.debug("Automatic update notice failed", exc_info=True)
|
|
1367
1378
|
|
|
1368
1379
|
|
|
1380
|
+
_REDACTED_OUTPUT_COMMANDS = frozenset({"validate", "plan", "test", "security"})
|
|
1381
|
+
_output_redaction: tuple[Any, Any, set[str]] | None = None
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _install_output_redaction(args: argparse.Namespace) -> None:
|
|
1385
|
+
"""CDS-SEC-071: redact resolved secret values from this command's stdout/stderr.
|
|
1386
|
+
|
|
1387
|
+
Runs for commands that print plans, diagnostics or findings, unless the
|
|
1388
|
+
user explicitly asked for unredacted output with --reveal-secrets.
|
|
1389
|
+
Failing to work out the secret set must not leave output unprotected, so
|
|
1390
|
+
it falls back to the secret-named CDS_* variables alone.
|
|
1391
|
+
"""
|
|
1392
|
+
global _output_redaction
|
|
1393
|
+
if args.command not in _REDACTED_OUTPUT_COMMANDS or getattr(args, "reveal_secrets", False):
|
|
1394
|
+
return
|
|
1395
|
+
declared: set[str] = set()
|
|
1396
|
+
env_file = None
|
|
1397
|
+
try:
|
|
1398
|
+
# The command resolves the profile itself and prints any warning;
|
|
1399
|
+
# don't print it a second time from this pre-pass.
|
|
1400
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
1401
|
+
profile_path = resolve_profile_path(args.profile)
|
|
1402
|
+
env_file = str(resolve_env_file_path(profile_path))
|
|
1403
|
+
declared = _collect_profile_env_vars(profile_path, getattr(args, "environment", None))[1]
|
|
1404
|
+
except Exception: # noqa: BLE001 - redaction must never break the command itself
|
|
1405
|
+
_LOGGER.debug("Could not collect declared secrets for output redaction", exc_info=True)
|
|
1406
|
+
secret_values = resolved_secret_values(declared, env_file)
|
|
1407
|
+
if not secret_values:
|
|
1408
|
+
return
|
|
1409
|
+
hits: set[str] = set()
|
|
1410
|
+
_output_redaction = (sys.stdout, sys.stderr, hits)
|
|
1411
|
+
sys.stdout = RedactingStream(sys.stdout, secret_values, hits)
|
|
1412
|
+
sys.stderr = RedactingStream(sys.stderr, secret_values, hits)
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _finish_output_redaction() -> None:
|
|
1416
|
+
global _output_redaction
|
|
1417
|
+
if _output_redaction is None:
|
|
1418
|
+
return
|
|
1419
|
+
original_stdout, original_stderr, hits = _output_redaction
|
|
1420
|
+
_output_redaction = None
|
|
1421
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1422
|
+
if isinstance(stream, RedactingStream):
|
|
1423
|
+
stream.finish()
|
|
1424
|
+
else:
|
|
1425
|
+
stream.flush()
|
|
1426
|
+
sys.stdout, sys.stderr = original_stdout, original_stderr
|
|
1427
|
+
if hits:
|
|
1428
|
+
print(
|
|
1429
|
+
Diagnostic(
|
|
1430
|
+
level="warning",
|
|
1431
|
+
code=CLI_OUTPUT_REDACTED_CODE,
|
|
1432
|
+
message=(
|
|
1433
|
+
"[CDS-SEC-071] Output contained the resolved value of "
|
|
1434
|
+
f"{', '.join(sorted(hits))}; it was redacted. Find where the literal "
|
|
1435
|
+
"value is being copied into config and use a secrets.<alias> reference instead."
|
|
1436
|
+
),
|
|
1437
|
+
path="cli-output",
|
|
1438
|
+
).format(),
|
|
1439
|
+
file=sys.stderr,
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
|
|
1369
1443
|
def main() -> int:
|
|
1370
1444
|
try:
|
|
1371
1445
|
return _run()
|
|
1372
1446
|
finally:
|
|
1447
|
+
_finish_output_redaction()
|
|
1373
1448
|
_maybe_notify_update()
|
|
1374
1449
|
|
|
1375
1450
|
|
|
@@ -1897,6 +1972,8 @@ def _run() -> int:
|
|
|
1897
1972
|
if args.command in {"validate", "render", "up", "down", "test", "state", "security"}:
|
|
1898
1973
|
args.target = getattr(args, "target", None) or load_saved_target() or "compose"
|
|
1899
1974
|
|
|
1975
|
+
_install_output_redaction(args)
|
|
1976
|
+
|
|
1900
1977
|
if args.command == "validate":
|
|
1901
1978
|
try:
|
|
1902
1979
|
profile_path = resolve_profile_path(args.profile)
|
|
@@ -2158,7 +2235,7 @@ def _run() -> int:
|
|
|
2158
2235
|
|
|
2159
2236
|
env_file = str(resolve_env_file_path(str(source_profile)))
|
|
2160
2237
|
if args.target == "helm":
|
|
2161
|
-
code, render_diags = _render_helm_chart(plan, output_path, args.force)
|
|
2238
|
+
code, render_diags = _render_helm_chart(plan, output_path, args.force, env_file=env_file)
|
|
2162
2239
|
all_diags = render_diags
|
|
2163
2240
|
if code != 0:
|
|
2164
2241
|
_record_audit("render", str(source_profile), "failure", plan=plan)
|
|
@@ -2222,7 +2299,7 @@ def _run() -> int:
|
|
|
2222
2299
|
)
|
|
2223
2300
|
|
|
2224
2301
|
if args.target == "helm":
|
|
2225
|
-
code, render_diags = _render_helm_chart(plan, output_path, args.force)
|
|
2302
|
+
code, render_diags = _render_helm_chart(plan, output_path, args.force, env_file=env_file)
|
|
2226
2303
|
all_diags = all_diags + render_diags
|
|
2227
2304
|
if code != 0:
|
|
2228
2305
|
_record_audit("render", profile_or_plan, "failure", environment=args.environment, plan=plan)
|
|
@@ -2302,7 +2379,7 @@ def _run() -> int:
|
|
|
2302
2379
|
if args.target == "helm":
|
|
2303
2380
|
project_root = resolve_project_root(profile_path)
|
|
2304
2381
|
chart_dir = (Path(args.chart_dir) if args.chart_dir else project_root / "chart").resolve()
|
|
2305
|
-
code, render_diags = _render_helm_chart(plan, chart_dir, force=True)
|
|
2382
|
+
code, render_diags = _render_helm_chart(plan, chart_dir, force=True, env_file=env_file)
|
|
2306
2383
|
all_diags.extend(render_diags)
|
|
2307
2384
|
if code != 0 or has_errors(all_diags):
|
|
2308
2385
|
print_diagnostics(all_diags)
|
cli/preflight.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
5
6
|
import shutil
|
|
6
7
|
import socket
|
|
8
|
+
import stat
|
|
7
9
|
import subprocess # nosec B404
|
|
8
10
|
from dataclasses import dataclass
|
|
9
11
|
from pathlib import Path
|
|
@@ -88,17 +90,156 @@ def _check_runtime(runtime: dict[str, Any]) -> list[PreflightCheck]:
|
|
|
88
90
|
"Docker Compose is unavailable. Install the Compose plugin.",
|
|
89
91
|
)
|
|
90
92
|
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
daemon_check, docker_info = _check_daemon()
|
|
94
|
+
checks.append(daemon_check)
|
|
95
|
+
if docker_info is not None:
|
|
96
|
+
checks.extend(_check_host_hardening(docker_info))
|
|
97
|
+
return checks
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_daemon() -> tuple[PreflightCheck, dict[str, Any] | None]:
|
|
101
|
+
"""Probe the daemon with one `docker info` call; also return its parsed JSON."""
|
|
102
|
+
command = ["docker", "info", "--format", "{{json .}}"]
|
|
103
|
+
try:
|
|
104
|
+
result = subprocess.run( # nosec B603 # noqa: S603
|
|
105
|
+
command,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
timeout=15,
|
|
97
109
|
)
|
|
98
|
-
)
|
|
110
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
111
|
+
result = None
|
|
112
|
+
|
|
113
|
+
if result is None or result.returncode != 0:
|
|
114
|
+
message = "Docker daemon is unreachable. Start Docker and verify access with `docker info`."
|
|
115
|
+
detail = ((result.stderr or "").strip().splitlines() or [""])[0][:200] if result else ""
|
|
116
|
+
if detail:
|
|
117
|
+
message = f"{message} ({detail})"
|
|
118
|
+
return PreflightCheck("FAIL", "runtime.daemon", message), None
|
|
119
|
+
|
|
120
|
+
info: dict[str, Any] | None = None
|
|
121
|
+
try:
|
|
122
|
+
parsed = json.loads(result.stdout or "")
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
parsed = None
|
|
125
|
+
if isinstance(parsed, dict):
|
|
126
|
+
info = parsed
|
|
127
|
+
return PreflightCheck("PASS", "runtime.daemon", "Docker daemon is reachable."), info
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_host_hardening(info: dict[str, Any]) -> list[PreflightCheck]:
|
|
131
|
+
"""
|
|
132
|
+
Inspect host/daemon settings from `docker info` without privileged access.
|
|
133
|
+
|
|
134
|
+
All findings are advisory (PASS/WARN): the host baseline in
|
|
135
|
+
docs/docker-host-hardening.md is an operator responsibility, so these
|
|
136
|
+
checks never fail preflight.
|
|
137
|
+
"""
|
|
138
|
+
# Docker Desktop's daemon runs in a managed VM the operator cannot harden.
|
|
139
|
+
if "docker desktop" in str(info.get("OperatingSystem", "")).lower():
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
raw_options = info.get("SecurityOptions")
|
|
143
|
+
options = {str(o) for o in raw_options} if isinstance(raw_options, list) else set()
|
|
144
|
+
rootless = "name=rootless" in options
|
|
145
|
+
checks = [
|
|
146
|
+
_host_check(
|
|
147
|
+
rootless,
|
|
148
|
+
"host.rootless",
|
|
149
|
+
"Docker daemon runs in rootless mode.",
|
|
150
|
+
"Docker daemon runs as root. Prefer rootless mode (HOST-001).",
|
|
151
|
+
),
|
|
152
|
+
_host_check(
|
|
153
|
+
rootless or "name=userns" in options,
|
|
154
|
+
"host.userns",
|
|
155
|
+
"User namespace isolation is active.",
|
|
156
|
+
"User namespace remapping is not enabled (HOST-002).",
|
|
157
|
+
),
|
|
158
|
+
_host_check(
|
|
159
|
+
any(o.startswith("name=seccomp") for o in options)
|
|
160
|
+
and "name=seccomp,profile=unconfined" not in options,
|
|
161
|
+
"host.seccomp",
|
|
162
|
+
"Daemon default seccomp profile is active.",
|
|
163
|
+
"Daemon default seccomp profile is not active (HOST-003).",
|
|
164
|
+
),
|
|
165
|
+
_host_check(
|
|
166
|
+
info.get("LoggingDriver") not in {None, "", "none"},
|
|
167
|
+
"host.logging",
|
|
168
|
+
f'Daemon logging driver "{info.get("LoggingDriver")}" is enabled '
|
|
169
|
+
"(log rotation is not verified; check daemon.json, HOST-004).",
|
|
170
|
+
"Daemon logging driver is disabled; container logs are not retained (HOST-004).",
|
|
171
|
+
),
|
|
172
|
+
_host_check(
|
|
173
|
+
info.get("LiveRestoreEnabled") is True,
|
|
174
|
+
"host.live-restore",
|
|
175
|
+
"Live restore is enabled.",
|
|
176
|
+
"Live restore is not enabled; daemon restarts stop containers (HOST-005).",
|
|
177
|
+
),
|
|
178
|
+
]
|
|
179
|
+
socket_check = _check_socket_exposure(rootless=rootless)
|
|
180
|
+
if socket_check is not None:
|
|
181
|
+
checks.append(socket_check)
|
|
99
182
|
return checks
|
|
100
183
|
|
|
101
184
|
|
|
185
|
+
def _host_check(ok: bool, name: str, pass_message: str, warn_message: str) -> PreflightCheck:
|
|
186
|
+
return PreflightCheck("PASS" if ok else "WARN", name, pass_message if ok else warn_message)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _uses_non_default_context() -> bool:
|
|
190
|
+
"""True when the Docker CLI targets a named context (possibly a remote daemon)."""
|
|
191
|
+
context = os.environ.get("DOCKER_CONTEXT", "").strip()
|
|
192
|
+
if not context:
|
|
193
|
+
config_dir = Path(os.environ.get("DOCKER_CONFIG") or Path.home() / ".docker")
|
|
194
|
+
try:
|
|
195
|
+
config = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
|
196
|
+
except (OSError, ValueError):
|
|
197
|
+
return False
|
|
198
|
+
context = str(config.get("currentContext", "")).strip() if isinstance(config, dict) else ""
|
|
199
|
+
return context not in {"", "default"}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _check_socket_exposure(rootless: bool = False) -> PreflightCheck | None:
|
|
203
|
+
"""Return None when the socket cannot be meaningfully inspected."""
|
|
204
|
+
docker_host = os.environ.get("DOCKER_HOST", "").strip()
|
|
205
|
+
if not docker_host and _uses_non_default_context():
|
|
206
|
+
return None
|
|
207
|
+
scheme = docker_host.lower().split("://", 1)[0] if "://" in docker_host else ""
|
|
208
|
+
if scheme in {"tcp", "http"}:
|
|
209
|
+
# Docker treats any non-empty DOCKER_TLS_VERIFY value as enabled.
|
|
210
|
+
if os.environ.get("DOCKER_TLS_VERIFY", "").strip():
|
|
211
|
+
return PreflightCheck(
|
|
212
|
+
"PASS", "host.socket", "Daemon is reached over TCP with TLS verification."
|
|
213
|
+
)
|
|
214
|
+
return PreflightCheck(
|
|
215
|
+
"WARN",
|
|
216
|
+
"host.socket",
|
|
217
|
+
"DOCKER_HOST uses TCP without DOCKER_TLS_VERIFY; "
|
|
218
|
+
"an unauthenticated daemon socket grants root on the host (HOST-006).",
|
|
219
|
+
)
|
|
220
|
+
if scheme == "ssh":
|
|
221
|
+
return PreflightCheck("PASS", "host.socket", "Daemon is reached over SSH.")
|
|
222
|
+
if scheme not in {"", "unix"} or os.name == "nt":
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
default_socket = "/var/run/docker.sock"
|
|
226
|
+
runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "").strip()
|
|
227
|
+
if rootless and runtime_dir:
|
|
228
|
+
default_socket = str(Path(runtime_dir) / "docker.sock")
|
|
229
|
+
socket_path = Path(docker_host.removeprefix("unix://") or default_socket)
|
|
230
|
+
try:
|
|
231
|
+
mode = socket_path.stat().st_mode
|
|
232
|
+
except OSError:
|
|
233
|
+
return None
|
|
234
|
+
if mode & stat.S_IWOTH:
|
|
235
|
+
return PreflightCheck(
|
|
236
|
+
"WARN",
|
|
237
|
+
"host.socket",
|
|
238
|
+
f"Daemon socket {socket_path} is world-writable (HOST-006).",
|
|
239
|
+
)
|
|
240
|
+
return PreflightCheck("PASS", "host.socket", "Daemon socket is not world-writable.")
|
|
241
|
+
|
|
242
|
+
|
|
102
243
|
def _run_runtime_command(
|
|
103
244
|
command: list[str],
|
|
104
245
|
name: str,
|
cli/renderer.py
CHANGED
|
@@ -16,6 +16,11 @@ import yaml
|
|
|
16
16
|
from .constants import MAX_NESTING_DEPTH, MaxNestingDepthExceeded
|
|
17
17
|
from .diagnostics import Diagnostic
|
|
18
18
|
from .loader import resolve_module_dir
|
|
19
|
+
from .secret_leak import (
|
|
20
|
+
check_generated_text,
|
|
21
|
+
declared_secret_env_names,
|
|
22
|
+
resolved_secret_values,
|
|
23
|
+
)
|
|
19
24
|
from .utils import _atomic_write
|
|
20
25
|
|
|
21
26
|
|
|
@@ -30,12 +35,12 @@ def render_compose(
|
|
|
30
35
|
Args:
|
|
31
36
|
plan: Composition plan.
|
|
32
37
|
output_path: Optional output file path.
|
|
33
|
-
env_file:
|
|
38
|
+
env_file: .env path used to look up resolved secret values so the
|
|
39
|
+
output file is never written if it would contain one.
|
|
34
40
|
|
|
35
41
|
Returns:
|
|
36
42
|
Tuple of (output_yaml, diagnostics).
|
|
37
43
|
"""
|
|
38
|
-
_ = env_file
|
|
39
44
|
diagnostics: list[Diagnostic] = []
|
|
40
45
|
secrets = plan.get("secrets", {})
|
|
41
46
|
compose_dir = Path(output_path).resolve().parent if output_path else Path.cwd().resolve()
|
|
@@ -146,9 +151,17 @@ def render_compose(
|
|
|
146
151
|
diagnostics.extend(_check_unresolved_expressions(output))
|
|
147
152
|
|
|
148
153
|
if output_path:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
# CDS-SEC-030: scan before writing so a leaking file never lands on disk.
|
|
155
|
+
leak_diags = check_generated_text(
|
|
156
|
+
output,
|
|
157
|
+
output_path,
|
|
158
|
+
resolved_secret_values(declared_secret_env_names(plan), env_file),
|
|
159
|
+
)
|
|
160
|
+
diagnostics.extend(leak_diags)
|
|
161
|
+
if not leak_diags:
|
|
162
|
+
path = Path(output_path)
|
|
163
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
_atomic_write(path, output)
|
|
152
165
|
|
|
153
166
|
return output, diagnostics
|
|
154
167
|
|
cli/resources/rule-schema.json
CHANGED
cli/resources/rule-set.json
CHANGED
|
@@ -156,8 +156,8 @@
|
|
|
156
156
|
},
|
|
157
157
|
{
|
|
158
158
|
"id": "CDS-SEC-006",
|
|
159
|
-
"$comment": "
|
|
160
|
-
"title": "Resolved secret present in
|
|
159
|
+
"$comment": "Enforced entirely in code by _check_rendered_secret_values in cli/security.py, not the declarative match engine: it looks for the literal value of every known secret (declared under spec.secrets.values or a secret-named CDS_* variable of at least 8 characters, per cli/secret_leak.py) anywhere in the rendered Compose model. CDS only ever renders ${CDS_*} placeholders, so a literal value means a secret was copied into config. The match block below is therefore documentation only. This rule was split by #356: written files are CDS-SEC-030 and printed CLI output is CDS-SEC-071. `enabled` stays false because `scope: [\"none\"]` rules can never produce a declarative-engine finding (see the scope-none `enabled` invariant test); `codeEnforced` is the actual on/off switch.",
|
|
160
|
+
"title": "Resolved secret present in rendered Compose output",
|
|
161
161
|
"severity": "high",
|
|
162
162
|
"category": "secrets",
|
|
163
163
|
"complianceCategory": "secrets-management",
|
|
@@ -173,13 +173,14 @@
|
|
|
173
173
|
}
|
|
174
174
|
]
|
|
175
175
|
},
|
|
176
|
-
"message": "Rendered
|
|
176
|
+
"message": "Rendered Compose output contains a resolved secret value",
|
|
177
177
|
"whyItMatters": "Generated artifacts are often stored, shared, or captured by CI systems.",
|
|
178
178
|
"recommendation": [
|
|
179
179
|
"Redact secrets in plan and render output.",
|
|
180
180
|
"Avoid writing resolved secret values to generated files."
|
|
181
181
|
],
|
|
182
|
-
"enabled": false
|
|
182
|
+
"enabled": false,
|
|
183
|
+
"codeEnforced": true
|
|
183
184
|
},
|
|
184
185
|
{
|
|
185
186
|
"id": "CDS-SEC-010",
|
|
@@ -410,7 +411,7 @@
|
|
|
410
411
|
},
|
|
411
412
|
{
|
|
412
413
|
"id": "CDS-SEC-030",
|
|
413
|
-
"$comment": "
|
|
414
|
+
"$comment": "Enforced entirely in code by cli.secret_leak.check_generated_text, called from render_compose (docker-compose.yml) and render_helm (every chart file) before anything is written: if the content contains the literal value of a known secret (same value set as CDS-SEC-006) the file is not written and error E096 is reported, so cds render and cds up fail closed. `enabled` stays false because `scope: [\"none\"]` rules can never produce a declarative-engine finding; `codeEnforced` is the actual on/off switch. The match block below is documentation only.",
|
|
414
415
|
"title": "Generated file contains resolved secrets",
|
|
415
416
|
"severity": "high",
|
|
416
417
|
"category": "file-output-safety",
|
|
@@ -433,7 +434,8 @@
|
|
|
433
434
|
"Redact generated output or write secret references only.",
|
|
434
435
|
"Keep rendered artifacts free of resolved secrets."
|
|
435
436
|
],
|
|
436
|
-
"enabled": false
|
|
437
|
+
"enabled": false,
|
|
438
|
+
"codeEnforced": true
|
|
437
439
|
},
|
|
438
440
|
{
|
|
439
441
|
"id": "CDS-SEC-031",
|
|
@@ -463,12 +465,12 @@
|
|
|
463
465
|
},
|
|
464
466
|
{
|
|
465
467
|
"id": "CDS-SEC-032",
|
|
466
|
-
"$comment": "
|
|
468
|
+
"$comment": "Evaluated against the \"file-permissions\" scope: _flatten_file_permissions in cli/security.py stats the .env file the scan loads secrets from and emits its octal mode (e.g. \"0644\") as a synthetic <path>.permissions item; the \"permissions\" condition then flags any group (\"group-readable\") or other (\"world-readable\") read/write/execute bit, i.e. anything broader than owner-only. POSIX only: on Windows (os.name == \"nt\") no item is emitted, so the rule never fires there rather than reporting meaningless modes. A missing .env emits nothing. Secret files CDS itself writes (cds init) are created owner-only (0600). Filesystems without POSIX modes (e.g. WSL /mnt/c) report 0777 and always trigger the rule.",
|
|
467
469
|
"title": "Secret file permissions too broad",
|
|
468
470
|
"severity": "medium",
|
|
469
471
|
"category": "file-output-safety",
|
|
470
472
|
"complianceCategory": "secrets-management",
|
|
471
|
-
"scope": ["
|
|
473
|
+
"scope": ["file-permissions"],
|
|
472
474
|
"match": {
|
|
473
475
|
"any": [
|
|
474
476
|
{
|
|
@@ -485,7 +487,7 @@
|
|
|
485
487
|
"Set permissions to owner-only for files containing secrets.",
|
|
486
488
|
"Tighten file creation umask for generated secret artifacts."
|
|
487
489
|
],
|
|
488
|
-
"enabled":
|
|
490
|
+
"enabled": true
|
|
489
491
|
},
|
|
490
492
|
{
|
|
491
493
|
"id": "CDS-SEC-033",
|
|
@@ -755,7 +757,7 @@
|
|
|
755
757
|
},
|
|
756
758
|
{
|
|
757
759
|
"id": "CDS-SEC-071",
|
|
758
|
-
"$comment": "
|
|
760
|
+
"$comment": "Enforced entirely in code: cds plan, validate, test and security wrap stdout/stderr in cli.secret_leak.RedactingStream, which replaces the literal value of any known secret (same value set as CDS-SEC-006) with ***REDACTED*** before it is printed and then warns (W102) on stderr that redaction happened. Passing --reveal-secrets (where offered) disables the wrapper. `enabled` stays false because `scope: [\"none\"]` rules can never produce a declarative-engine finding; `codeEnforced` is the actual on/off switch. The match block below is documentation only.",
|
|
759
761
|
"title": "CLI plan or validate output prints secrets",
|
|
760
762
|
"severity": "high",
|
|
761
763
|
"category": "observability-leakage",
|
|
@@ -781,7 +783,8 @@
|
|
|
781
783
|
"Redact sensitive output by default.",
|
|
782
784
|
"Provide explicit unsafe flags for debugging only."
|
|
783
785
|
],
|
|
784
|
-
"enabled": false
|
|
786
|
+
"enabled": false,
|
|
787
|
+
"codeEnforced": true
|
|
785
788
|
},
|
|
786
789
|
{
|
|
787
790
|
"id": "CDS-SEC-072",
|
cli/secret_leak.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Value-based detection of resolved secrets in generated or printed output.
|
|
2
|
+
|
|
3
|
+
Backs the code-enforced rules CDS-SEC-006 (rendered Compose model),
|
|
4
|
+
CDS-SEC-030 (generated files) and CDS-SEC-071 (CLI output). Rather than
|
|
5
|
+
guessing from entropy, these look for the literal secret values CDS knows
|
|
6
|
+
about (the `CDS_*` variables from `.env`/the environment), so a hit is a real
|
|
7
|
+
leak: CDS only ever renders `${CDS_*}` placeholders, never resolved values.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, TextIO
|
|
15
|
+
|
|
16
|
+
from .diagnostics import Diagnostic
|
|
17
|
+
from .secrets import load_secrets_from_env
|
|
18
|
+
from .security_common import SECRET_KEY_RE
|
|
19
|
+
|
|
20
|
+
# Shorter values (e.g. "admin") would match ordinary words and mangle output;
|
|
21
|
+
# weak credentials are reported separately by the CDS-SEC-010 family.
|
|
22
|
+
MIN_SECRET_LENGTH = 8
|
|
23
|
+
|
|
24
|
+
REDACTED = "***REDACTED***"
|
|
25
|
+
|
|
26
|
+
GENERATED_FILE_LEAK_CODE = "E096"
|
|
27
|
+
CLI_OUTPUT_REDACTED_CODE = "W102"
|
|
28
|
+
|
|
29
|
+
# CDS_* variables that configure the CLI itself and are never secret even if
|
|
30
|
+
# their names happen to contain a secret-like word.
|
|
31
|
+
_NON_SECRET_ENV_NAMES = frozenset({
|
|
32
|
+
"CDS_CHECK_UPDATES",
|
|
33
|
+
"CDS_CHECK_IMAGE_DIGESTS",
|
|
34
|
+
"CDS_MODULE_PATH",
|
|
35
|
+
"CDS_PROFILE_PATH",
|
|
36
|
+
"CDS_CONFIG_PATH",
|
|
37
|
+
"CDS_AUDIT_LOG_DISABLE",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def declared_secret_env_names(plan: dict[str, Any] | None) -> set[str]:
|
|
42
|
+
"""Env var names a plan maps its secret aliases to (never their values)."""
|
|
43
|
+
if not isinstance(plan, dict):
|
|
44
|
+
return set()
|
|
45
|
+
secrets = plan.get("secrets", {})
|
|
46
|
+
if not isinstance(secrets, dict):
|
|
47
|
+
return set()
|
|
48
|
+
return {
|
|
49
|
+
value for alias, value in secrets.items()
|
|
50
|
+
if isinstance(value, str) and alias != value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolved_secret_values(
|
|
55
|
+
declared_env_names: set[str] | frozenset[str] | None = None,
|
|
56
|
+
env_file: str | None = None,
|
|
57
|
+
) -> dict[str, str]:
|
|
58
|
+
"""Return {env name: value} for every CDS_* variable that holds a secret.
|
|
59
|
+
|
|
60
|
+
A variable counts as secret when the profile declares it under
|
|
61
|
+
`spec.secrets.values` or its name looks secret-like. Unreadable env files
|
|
62
|
+
simply yield no extra values; the process environment is still covered.
|
|
63
|
+
"""
|
|
64
|
+
declared = declared_env_names or frozenset()
|
|
65
|
+
values, _ = load_secrets_from_env(Path(env_file) if env_file else None)
|
|
66
|
+
# load_secrets_from_env returns nothing when the env file is unreadable;
|
|
67
|
+
# always merging the process environment keeps that case fail-closed.
|
|
68
|
+
for name, value in os.environ.items():
|
|
69
|
+
if name.startswith("CDS_"):
|
|
70
|
+
values.setdefault(name, value)
|
|
71
|
+
result: dict[str, str] = {}
|
|
72
|
+
for name, value in values.items():
|
|
73
|
+
if name in _NON_SECRET_ENV_NAMES or len(value) < MIN_SECRET_LENGTH:
|
|
74
|
+
continue
|
|
75
|
+
if name in declared or SECRET_KEY_RE.search(name):
|
|
76
|
+
result[name] = value
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def find_secret_leaks(text: str, secret_values: dict[str, str]) -> list[tuple[str, int]]:
|
|
81
|
+
"""Return (env name, 1-based line) for each literal secret occurrence."""
|
|
82
|
+
leaks: list[tuple[str, int]] = []
|
|
83
|
+
for name, value in secret_values.items():
|
|
84
|
+
start = 0
|
|
85
|
+
while (index := text.find(value, start)) != -1:
|
|
86
|
+
leaks.append((name, text.count("\n", 0, index) + 1))
|
|
87
|
+
start = index + len(value)
|
|
88
|
+
return sorted(leaks, key=lambda leak: (leak[1], leak[0]))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_generated_text(
|
|
92
|
+
text: str,
|
|
93
|
+
label: str,
|
|
94
|
+
secret_values: dict[str, str],
|
|
95
|
+
) -> list[Diagnostic]:
|
|
96
|
+
"""CDS-SEC-030: one error per distinct secret found in generated content."""
|
|
97
|
+
diagnostics: list[Diagnostic] = []
|
|
98
|
+
seen: set[str] = set()
|
|
99
|
+
for name, line in find_secret_leaks(text, secret_values):
|
|
100
|
+
if name in seen:
|
|
101
|
+
continue
|
|
102
|
+
seen.add(name)
|
|
103
|
+
diagnostics.append(Diagnostic(
|
|
104
|
+
level="error",
|
|
105
|
+
code=GENERATED_FILE_LEAK_CODE,
|
|
106
|
+
message=(
|
|
107
|
+
f"[CDS-SEC-030] Generated content contains the resolved value of {name}; "
|
|
108
|
+
"refusing to write it. Render secrets as ${CDS_*} placeholders instead of "
|
|
109
|
+
"literal values (for example by moving the literal out of module/profile "
|
|
110
|
+
"config and into spec.secrets.values)."
|
|
111
|
+
),
|
|
112
|
+
path=f"{label}:{line}",
|
|
113
|
+
))
|
|
114
|
+
return diagnostics
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _redaction_candidates(secret_values: dict[str, str]) -> list[tuple[str, str]]:
|
|
118
|
+
"""Secret values, longest first, plus each long line of multi-line values.
|
|
119
|
+
|
|
120
|
+
Output is redacted line by line, so a multi-line secret (e.g. a PEM key)
|
|
121
|
+
can only be caught through its individual lines.
|
|
122
|
+
"""
|
|
123
|
+
candidates: list[tuple[str, str]] = []
|
|
124
|
+
for name, value in secret_values.items():
|
|
125
|
+
candidates.append((name, value))
|
|
126
|
+
if "\n" in value:
|
|
127
|
+
stripped = (line.strip() for line in value.splitlines())
|
|
128
|
+
candidates.extend((name, line) for line in stripped if len(line) >= MIN_SECRET_LENGTH)
|
|
129
|
+
return sorted(candidates, key=lambda item: -len(item[1]))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def redact_text(text: str, secret_values: dict[str, str]) -> tuple[str, set[str]]:
|
|
133
|
+
"""Replace literal secret values with a marker; return (text, names hit).
|
|
134
|
+
|
|
135
|
+
Longer values are replaced first so a secret that contains another secret
|
|
136
|
+
as a substring is still fully redacted.
|
|
137
|
+
"""
|
|
138
|
+
hit: set[str] = set()
|
|
139
|
+
for name, value in _redaction_candidates(secret_values):
|
|
140
|
+
if value in text:
|
|
141
|
+
hit.add(name)
|
|
142
|
+
text = text.replace(value, REDACTED)
|
|
143
|
+
return text, hit
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class RedactingStream(io.TextIOBase):
|
|
147
|
+
"""Text stream wrapper that redacts known secret values (CDS-SEC-071).
|
|
148
|
+
|
|
149
|
+
Output is buffered per line so a value is still caught when a single
|
|
150
|
+
`print()` reaches the stream in several `write()` calls.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, wrapped: TextIO, secret_values: dict[str, str], hits: set[str]):
|
|
154
|
+
self._wrapped = wrapped
|
|
155
|
+
self._secrets = secret_values
|
|
156
|
+
self._hits = hits
|
|
157
|
+
self._buffer = ""
|
|
158
|
+
|
|
159
|
+
def writable(self) -> bool:
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def encoding(self) -> str: # type: ignore[override]
|
|
164
|
+
return getattr(self._wrapped, "encoding", "utf-8")
|
|
165
|
+
|
|
166
|
+
def isatty(self) -> bool:
|
|
167
|
+
return self._wrapped.isatty()
|
|
168
|
+
|
|
169
|
+
def fileno(self) -> int:
|
|
170
|
+
return self._wrapped.fileno()
|
|
171
|
+
|
|
172
|
+
def write(self, text: str) -> int:
|
|
173
|
+
self._buffer += text
|
|
174
|
+
head, newline, tail = self._buffer.rpartition("\n")
|
|
175
|
+
if newline:
|
|
176
|
+
self._emit(head + newline)
|
|
177
|
+
self._buffer = tail
|
|
178
|
+
return len(text)
|
|
179
|
+
|
|
180
|
+
def flush(self) -> None:
|
|
181
|
+
# Hold back only a tail that could be the start of a secret so a value
|
|
182
|
+
# split across a flush is still caught; prompts are unaffected.
|
|
183
|
+
keep = self._partial_secret_suffix()
|
|
184
|
+
cut = len(self._buffer) - keep
|
|
185
|
+
self._emit(self._buffer[:cut])
|
|
186
|
+
self._buffer = self._buffer[cut:]
|
|
187
|
+
self._wrapped.flush()
|
|
188
|
+
|
|
189
|
+
def finish(self) -> None:
|
|
190
|
+
"""Emit everything still buffered; call once before unwrapping."""
|
|
191
|
+
self._emit(self._buffer)
|
|
192
|
+
self._buffer = ""
|
|
193
|
+
self._wrapped.flush()
|
|
194
|
+
|
|
195
|
+
def _partial_secret_suffix(self) -> int:
|
|
196
|
+
longest = 0
|
|
197
|
+
for _, value in _redaction_candidates(self._secrets):
|
|
198
|
+
for size in range(min(len(value) - 1, len(self._buffer)), longest, -1):
|
|
199
|
+
if self._buffer.endswith(value[:size]):
|
|
200
|
+
longest = size
|
|
201
|
+
break
|
|
202
|
+
return longest
|
|
203
|
+
|
|
204
|
+
def _emit(self, text: str) -> None:
|
|
205
|
+
if not text:
|
|
206
|
+
return
|
|
207
|
+
redacted, hit = redact_text(text, self._secrets)
|
|
208
|
+
self._hits.update(hit)
|
|
209
|
+
self._wrapped.write(redacted)
|
|
210
|
+
|
cli/security.py
CHANGED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
|
+
import stat
|
|
12
13
|
from dataclasses import dataclass
|
|
13
14
|
from importlib.resources import files
|
|
14
15
|
from importlib.resources.abc import Traversable
|
|
@@ -23,6 +24,11 @@ from .loader import load_yaml_file, resolve_module_file
|
|
|
23
24
|
from .planner import build_plan
|
|
24
25
|
from .renderer import _compose_service_name, render_compose
|
|
25
26
|
from .resolver import parse_contract_ref
|
|
27
|
+
from .secret_leak import (
|
|
28
|
+
declared_secret_env_names,
|
|
29
|
+
find_secret_leaks,
|
|
30
|
+
resolved_secret_values,
|
|
31
|
+
)
|
|
26
32
|
from .secrets import load_secrets_from_env
|
|
27
33
|
from .security_common import SECRET_KEY_RE, SEVERITY_ORDER, infer_profile_class
|
|
28
34
|
|
|
@@ -40,6 +46,15 @@ _ENV_SCOPES = {
|
|
|
40
46
|
"runtime",
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
# Rules in this scope are matched against the permission bits of secret-bearing
|
|
50
|
+
# files on disk (currently the .env file), not their contents.
|
|
51
|
+
_FILE_PERMISSION_SCOPES = {
|
|
52
|
+
"file-permissions",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_GROUP_ACCESS_BITS = stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP
|
|
56
|
+
_OTHER_ACCESS_BITS = stat.S_IROTH | stat.S_IWOTH | stat.S_IXOTH
|
|
57
|
+
|
|
43
58
|
# Rules in this scope are matched against the *rendered* Compose service
|
|
44
59
|
# definitions (command/entrypoint/logging), not the profile or .env inputs.
|
|
45
60
|
# This is the only way to see where a module's implementation template
|
|
@@ -203,6 +218,50 @@ def _flatten_env_inputs(
|
|
|
203
218
|
return items
|
|
204
219
|
|
|
205
220
|
|
|
221
|
+
def _is_windows() -> bool:
|
|
222
|
+
return os.name == "nt"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _flatten_file_permissions(env_file: str | None) -> list[tuple[str, str, Any]]:
|
|
226
|
+
"""
|
|
227
|
+
Emit the POSIX permission bits of the secret-bearing .env file as a
|
|
228
|
+
synthetic item whose value is the octal mode string (e.g. "0644").
|
|
229
|
+
|
|
230
|
+
POSIX mode bits don't describe access on Windows, so nothing is emitted
|
|
231
|
+
there rather than reporting meaningless (and always-broad) modes. A file
|
|
232
|
+
that can't be stat'ed is skipped: the missing/unreadable case is already
|
|
233
|
+
reported by the env loader.
|
|
234
|
+
"""
|
|
235
|
+
if _is_windows():
|
|
236
|
+
return []
|
|
237
|
+
env_path = Path(env_file) if env_file is not None else Path(".env")
|
|
238
|
+
try:
|
|
239
|
+
mode = stat.S_IMODE(env_path.stat().st_mode)
|
|
240
|
+
except OSError:
|
|
241
|
+
return []
|
|
242
|
+
return [("<env>", f"{_normalize_scan_path(env_path)}.permissions", f"{mode:04o}")]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _permission_mode(value: str) -> int | None:
|
|
246
|
+
try:
|
|
247
|
+
return int(value, 8)
|
|
248
|
+
except ValueError:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _permissions_match(policy: str, value: str) -> bool:
|
|
253
|
+
mode = _permission_mode(value)
|
|
254
|
+
if mode is None:
|
|
255
|
+
return False
|
|
256
|
+
if policy == "world-readable":
|
|
257
|
+
return bool(mode & _OTHER_ACCESS_BITS)
|
|
258
|
+
if policy == "group-readable":
|
|
259
|
+
return bool(mode & _GROUP_ACCESS_BITS)
|
|
260
|
+
if policy == "owner-only":
|
|
261
|
+
return not mode & (_GROUP_ACCESS_BITS | _OTHER_ACCESS_BITS)
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
206
265
|
def _flatten_rendered_leak_surfaces(
|
|
207
266
|
compose: dict[str, Any] | None,
|
|
208
267
|
service_to_module: dict[str, str] | None = None,
|
|
@@ -377,6 +436,8 @@ def _eval_condition(
|
|
|
377
436
|
return False
|
|
378
437
|
if "minLength" in cond and len(sval) < cond["minLength"]:
|
|
379
438
|
return False
|
|
439
|
+
if "permissions" in cond and not _permissions_match(cond["permissions"], sval):
|
|
440
|
+
return False
|
|
380
441
|
if "serviceTypes" in cond and _service_type_for_path(path) not in cond["serviceTypes"]:
|
|
381
442
|
return False
|
|
382
443
|
|
|
@@ -500,7 +561,8 @@ def _rule_matches(
|
|
|
500
561
|
"module": module_id,
|
|
501
562
|
"message": rule["message"],
|
|
502
563
|
"path": path,
|
|
503
|
-
|
|
564
|
+
# A file mode is not sensitive and is the whole point of the finding.
|
|
565
|
+
"value": _redact(value) if redact_values and "file-permissions" not in rule.get("scope", []) else value,
|
|
504
566
|
"recommendation": rule["recommendation"],
|
|
505
567
|
})
|
|
506
568
|
|
|
@@ -662,6 +724,56 @@ def _rule_enabled(rule_set: dict[str, Any], rule_id: str, key: str = "enabled",
|
|
|
662
724
|
return default
|
|
663
725
|
|
|
664
726
|
|
|
727
|
+
def _profile_declared_secret_env_names(profile: dict[str, Any]) -> set[str]:
|
|
728
|
+
values = profile.get("spec", {}).get("secrets", {}).get("values", {})
|
|
729
|
+
if not isinstance(values, dict):
|
|
730
|
+
return set()
|
|
731
|
+
return {
|
|
732
|
+
definition["env"]
|
|
733
|
+
for definition in values.values()
|
|
734
|
+
if isinstance(definition, dict) and isinstance(definition.get("env"), str)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _check_rendered_secret_values(
|
|
739
|
+
rendered_compose: dict[str, Any] | None,
|
|
740
|
+
service_to_module: dict[str, str],
|
|
741
|
+
secret_values: dict[str, str],
|
|
742
|
+
rule: dict[str, Any] | None,
|
|
743
|
+
redact_values: bool,
|
|
744
|
+
rule_enabled: bool,
|
|
745
|
+
) -> list[dict[str, Any]]:
|
|
746
|
+
"""CDS-SEC-006: a resolved secret value appears in the rendered Compose model.
|
|
747
|
+
|
|
748
|
+
Enforced in code (see the rule's $comment): CDS renders only ``${CDS_*}``
|
|
749
|
+
placeholders, so the literal value of a known secret anywhere in the
|
|
750
|
+
rendered document -- keys or values -- means it was copied in from
|
|
751
|
+
config rather than referenced.
|
|
752
|
+
"""
|
|
753
|
+
if not rule_enabled or rule is None or not isinstance(rendered_compose, dict) or not secret_values:
|
|
754
|
+
return []
|
|
755
|
+
|
|
756
|
+
findings: list[dict[str, Any]] = []
|
|
757
|
+
for path, value in _flatten(rendered_compose):
|
|
758
|
+
if not isinstance(value, str):
|
|
759
|
+
continue
|
|
760
|
+
leaked = sorted({name for name, _ in find_secret_leaks(value, secret_values)})
|
|
761
|
+
if not leaked:
|
|
762
|
+
continue
|
|
763
|
+
parts = path.split(".")
|
|
764
|
+
module_id = service_to_module.get(parts[1], parts[1]) if parts[0] == "services" and len(parts) > 1 else "<rendered>"
|
|
765
|
+
findings.append({
|
|
766
|
+
"rule_id": rule["id"],
|
|
767
|
+
"severity": rule["severity"],
|
|
768
|
+
"module": module_id,
|
|
769
|
+
"message": f"{rule['message']} ({', '.join(leaked)})",
|
|
770
|
+
"path": path,
|
|
771
|
+
"value": _redact(value) if redact_values else value,
|
|
772
|
+
"recommendation": rule["recommendation"],
|
|
773
|
+
})
|
|
774
|
+
return findings
|
|
775
|
+
|
|
776
|
+
|
|
665
777
|
def _check_production_plaintext_exposure(
|
|
666
778
|
profile: dict[str, Any],
|
|
667
779
|
profile_class: str,
|
|
@@ -1107,6 +1219,7 @@ def run_security_validation(
|
|
|
1107
1219
|
|
|
1108
1220
|
flat_profile = _flatten_profile_by_module(profile, profile_dir=profile_path.parent)
|
|
1109
1221
|
flat_env = _flatten_env_inputs(secrets, env_file)
|
|
1222
|
+
flat_permissions = _flatten_file_permissions(env_file)
|
|
1110
1223
|
|
|
1111
1224
|
# Planning and rendering the profile is only useful when some enabled
|
|
1112
1225
|
# rule actually declares the "rendered-compose" scope -- e.g. a custom
|
|
@@ -1118,8 +1231,10 @@ def run_security_validation(
|
|
|
1118
1231
|
plaintext_exposure_rule_enabled = _rule_enabled(rule_set, "CDS-SEC-074", key="codeEnforced")
|
|
1119
1232
|
backup_target_rule_enabled = _rule_enabled(rule_set, "CDS-SEC-080", key="codeEnforced")
|
|
1120
1233
|
admin_identity_rule_enabled = _rule_enabled(rule_set, "CDS-SEC-081", key="codeEnforced")
|
|
1234
|
+
rendered_secret_rule_enabled = _rule_enabled(rule_set, "CDS-SEC-006", key="codeEnforced")
|
|
1121
1235
|
needs_rendered_compose = (
|
|
1122
1236
|
(profile_class == "prod" and plaintext_exposure_rule_enabled)
|
|
1237
|
+
or rendered_secret_rule_enabled
|
|
1123
1238
|
or backup_target_rule_enabled
|
|
1124
1239
|
or admin_identity_rule_enabled
|
|
1125
1240
|
or any(
|
|
@@ -1155,6 +1270,12 @@ def run_security_validation(
|
|
|
1155
1270
|
redact_values=redact_values,
|
|
1156
1271
|
))
|
|
1157
1272
|
|
|
1273
|
+
if rule_scopes & _FILE_PERMISSION_SCOPES:
|
|
1274
|
+
findings.extend(_rule_matches(
|
|
1275
|
+
rule, flat_permissions, profile_class,
|
|
1276
|
+
redact_values=redact_values,
|
|
1277
|
+
))
|
|
1278
|
+
|
|
1158
1279
|
if rule_scopes & _RENDERED_COMPOSE_SCOPES:
|
|
1159
1280
|
findings.extend(_rule_matches(
|
|
1160
1281
|
rule, flat_rendered, profile_class,
|
|
@@ -1173,6 +1294,21 @@ def run_security_validation(
|
|
|
1173
1294
|
)
|
|
1174
1295
|
findings.extend(plaintext_findings)
|
|
1175
1296
|
|
|
1297
|
+
rendered_secret_rule = next(
|
|
1298
|
+
(rule for rule in rule_set["rules"] if rule.get("id") == "CDS-SEC-006"), None
|
|
1299
|
+
)
|
|
1300
|
+
findings.extend(_check_rendered_secret_values(
|
|
1301
|
+
rendered_compose=rendered_compose,
|
|
1302
|
+
service_to_module=service_to_module,
|
|
1303
|
+
secret_values=resolved_secret_values(
|
|
1304
|
+
_profile_declared_secret_env_names(profile) | declared_secret_env_names(rendered_plan),
|
|
1305
|
+
env_file,
|
|
1306
|
+
),
|
|
1307
|
+
rule=rendered_secret_rule,
|
|
1308
|
+
redact_values=redact_values,
|
|
1309
|
+
rule_enabled=rendered_secret_rule_enabled,
|
|
1310
|
+
))
|
|
1311
|
+
|
|
1176
1312
|
findings.extend(_check_backup_target_binding(
|
|
1177
1313
|
plan=rendered_plan,
|
|
1178
1314
|
rule_enabled=backup_target_rule_enabled,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: composable-data-stack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: CLI for validating and composing composable data stack profiles
|
|
5
5
|
Author: Ronald Hensbergen
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -32,7 +32,7 @@ Provides-Extra: dev
|
|
|
32
32
|
Requires-Dist: coverage>=7.6; extra == "dev"
|
|
33
33
|
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
34
34
|
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
35
|
-
Requires-Dist: ruff==0.17.
|
|
35
|
+
Requires-Dist: ruff==0.17.1; extra == "dev"
|
|
36
36
|
Dynamic: license-file
|
|
37
37
|
|
|
38
38
|
# 🚀 Composable Data Stack (CDS)
|
|
@@ -3,24 +3,25 @@ cli/audit_log.py,sha256=YMm1GB8Lzcp3p7g06SGe39nv-C-McN1wWNm4Ql8OtWY,5726
|
|
|
3
3
|
cli/composer.py,sha256=xaCSmHqTVzhJE2N-ogYii7SnzN-k8C9MuV1B12b6V9s,14128
|
|
4
4
|
cli/constants.py,sha256=cp4135iisFhiOfhxm_jWI99sdQAi4SbSAX7qRJGNylQ,390
|
|
5
5
|
cli/diagnostics.py,sha256=Vu_uY7OPJqrAvrG-DAcIjQVMiQ4WcGWSl32UYCtOCmA,260
|
|
6
|
-
cli/getter.py,sha256=
|
|
6
|
+
cli/getter.py,sha256=obxQ1sPJ8o_hYk09qSj27PjwwpF1Nf13mHaxzNkQSmA,36576
|
|
7
7
|
cli/graph.py,sha256=TNx0EuTVxN4G0EIs_9eX0_x7-So3XVMh6AYjgukMMZs,1828
|
|
8
8
|
cli/image_digest_check.py,sha256=xO2RQY7ShEJuC_tp5w92DjhFDOp3Joqou7lv7nLCvzA,7285
|
|
9
9
|
cli/image_updates.py,sha256=WCA5YNRJ2ctWJPI7xmja5T7qKJTndqpJ0qVp8dbvR8Q,11833
|
|
10
10
|
cli/image_verification.py,sha256=rlF_ZboHM07CIV1X99vdcmI4J9kMugaICPz13HbjsSQ,19758
|
|
11
|
-
cli/k8s_renderer.py,sha256=
|
|
11
|
+
cli/k8s_renderer.py,sha256=m4MZhjcZTYGT1IxmZLLVLGEciUzS5vjolLDP7o-ITts,52582
|
|
12
12
|
cli/k8s_runner.py,sha256=ICfN4oYdQxowpoNUD8iPqxd90Wponpm867tNAxup4Mw,12615
|
|
13
13
|
cli/k8s_security.py,sha256=-pbtNtbxYuca9yiaGNkwJOyAuXzSHrSs9mCvrVGcbPo,6568
|
|
14
14
|
cli/loader.py,sha256=VufkoXW5pdYjq2zneBA12KKJq24G54JQgiKBRDGYBps,11847
|
|
15
|
-
cli/main.py,sha256=
|
|
15
|
+
cli/main.py,sha256=8pVSp2Iv9BsNeP14MWJCvisp7LSnMB6i8y7kv1l9paM,138199
|
|
16
16
|
cli/overlay.py,sha256=dZ0bJqgJ-52cFU3ZVYER9leJ7Ssqb5vI1ZVkPXd81ME,20669
|
|
17
17
|
cli/planner.py,sha256=zNOFSYnlgrX9QxYR1NBMmTstQ-2PCRAx0o0OFpGUA3c,33846
|
|
18
|
-
cli/preflight.py,sha256=
|
|
19
|
-
cli/renderer.py,sha256=
|
|
18
|
+
cli/preflight.py,sha256=Jsx8jtc2nVIGSzVrIeWBoEIc7InPS4Th3IN6o_RgrpY,19946
|
|
19
|
+
cli/renderer.py,sha256=H2IpA5lOQUtDy2bBEvd_Fa5uXWPV9YFtJHNrMS25sik,37472
|
|
20
20
|
cli/report.py,sha256=w-i-5tCcLtF8sqv0pOM1laIZPgSGjWJDVzCdOHz3uQ8,9916
|
|
21
21
|
cli/resolver.py,sha256=JPmfjpT6MMLbF4-BYRwSPqWkyXjBZN3oCBv1AnD0gU0,2923
|
|
22
|
+
cli/secret_leak.py,sha256=Ph3bGkhXUy_KHKBXyRa1E4A1398kAGA6tXHoA-8qQWM,7633
|
|
22
23
|
cli/secrets.py,sha256=0w8MrZ-K-YVn7AZV0PlsJ43NUtHL-2kQFgmf2_4MsJ8,5886
|
|
23
|
-
cli/security.py,sha256=
|
|
24
|
+
cli/security.py,sha256=UY4LCxh-8zzVNJ4CjaEVQgbWqMYiifHUOiTwtgy2N_4,53688
|
|
24
25
|
cli/security_common.py,sha256=oqJobLupYUDqNAe0r45GCBUsxgadtB1avoJMRm_Vcu0,6624
|
|
25
26
|
cli/state.py,sha256=6NCh3P9vf0V_Q4FeaxRm0JVRiwOTP125sX-IRyjSlQ8,5579
|
|
26
27
|
cli/up_runner.py,sha256=-w9uJ3Ti1J_Gl7bIy1OuvSkyRJepAdz2yq6jNX8fVlw,14117
|
|
@@ -33,11 +34,11 @@ cli/resources/compatibility-registry.schema.json,sha256=xYrquLDPORO-yJEbUa01xOZP
|
|
|
33
34
|
cli/resources/contract.schema.json,sha256=wP6ElZDm4I2CwHY86FRKV37ot6J20oP6vxWD_JBZq3Q,1644
|
|
34
35
|
cli/resources/module.schema.json,sha256=gehPtHEyfWz-Pcm9UavJOF7S3ctDvllZgi8cKolxUm8,18359
|
|
35
36
|
cli/resources/profile.schema.json,sha256=rJuQcTZtaGEBOc6AkJLrS3wg1acGMzsyvZFXm8ouVK0,7664
|
|
36
|
-
cli/resources/rule-schema.json,sha256=
|
|
37
|
-
cli/resources/rule-set.json,sha256=
|
|
38
|
-
composable_data_stack-0.
|
|
39
|
-
composable_data_stack-0.
|
|
40
|
-
composable_data_stack-0.
|
|
41
|
-
composable_data_stack-0.
|
|
42
|
-
composable_data_stack-0.
|
|
43
|
-
composable_data_stack-0.
|
|
37
|
+
cli/resources/rule-schema.json,sha256=1D2rA1GdwXlwFbftWK3eaNf0jVo4mxv2HXdMfp0uRIs,8254
|
|
38
|
+
cli/resources/rule-set.json,sha256=Uiq3yYzYX-BPO5SYXDuvkCo3uDP-93kY1WhLvcD1slE,38832
|
|
39
|
+
composable_data_stack-0.13.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
40
|
+
composable_data_stack-0.13.0.dist-info/METADATA,sha256=z8Cbp1AdJZYxj_RKnk7bis_b1D47bftdqMq99OOdLgA,37127
|
|
41
|
+
composable_data_stack-0.13.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
42
|
+
composable_data_stack-0.13.0.dist-info/entry_points.txt,sha256=GBhnbsVzmlnBKLC5Do-tirjz3hTzOfgGXGuZNQxb5ak,38
|
|
43
|
+
composable_data_stack-0.13.0.dist-info/top_level.txt,sha256=2ImG917oaVHlm0nP9oJE-Qrgs-fq_fGWgba2H1f8fpE,4
|
|
44
|
+
composable_data_stack-0.13.0.dist-info/RECORD,,
|
{composable_data_stack-0.12.0.dist-info → composable_data_stack-0.13.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{composable_data_stack-0.12.0.dist-info → composable_data_stack-0.13.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{composable_data_stack-0.12.0.dist-info → composable_data_stack-0.13.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|