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 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
- checks.append(
92
- _run_runtime_command(
93
- ["docker", "info"],
94
- "runtime.daemon",
95
- "Docker daemon is reachable.",
96
- "Docker daemon is unreachable. Start Docker and verify access with `docker info`.",
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: Reserved for compatibility; not used.
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
- path = Path(output_path)
150
- path.parent.mkdir(parents=True, exist_ok=True)
151
- _atomic_write(path, output)
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
 
@@ -77,6 +77,7 @@
77
77
  "service-env",
78
78
  "runtime",
79
79
  "rendered-compose",
80
+ "file-permissions",
80
81
  "none"
81
82
  ]
82
83
  },
@@ -156,8 +156,8 @@
156
156
  },
157
157
  {
158
158
  "id": "CDS-SEC-006",
159
- "$comment": "Deferred: this rule is about secrets leaking into rendered Compose, generated artifacts, or CLI text output after resolution. The current scanner only flattens profile inputs and loaded .env secrets, so enabling a non-'none' scope here would still never inspect the output surfaces this rule names.",
160
- "title": "Resolved secret present in generated or CLI output",
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 or planned output appears to contain a resolved secret",
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": "Deferred: this rule needs an on-disk generated-file scan after render/write. The current matcher only sees profile values and loaded .env secrets, not file contents created by cds render or cds up.",
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": "Deferred: the rule schema exposes a permissions condition, but cli/security.py does not evaluate it and the scanner does not stat files on disk. Supporting this needs file metadata collection plus condition evaluation for actual mode bits.",
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": ["none"],
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": false
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": "Deferred: this rule is about literal CLI plan/validate output text, which is produced after formatting findings/diagnostics. The current matcher has no capture of stdout/stderr or rendered plan text to scan.",
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
- "value": _redact(value) if redact_values else value,
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.12.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.0; extra == "dev"
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=FRyTRVHDdJljHtUH6uZk_WSZCJne8MVXsTUvhJhn-I0,36542
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=GiNn_Yu1IeFba9Rz5oFnr80QWYhqWtaf7hOOZ_Y0J8I,52063
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=vZ8HyX0nxuUD_u4pdVzTpCGwSRpmXdAQbBDw_nn0B2A,135016
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=Juby_q6mHghwwGRFvVdpf0AkPKHxR0JtoEo73neRWCQ,14089
19
- cli/renderer.py,sha256=PgwOifSbinwStTbM33JkROqixO9MYyH0rh8rvNTITtE,36945
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=Qq4xh5ra-Eglf4Hj0UQLG7kzFqKnV_XP1oXvJoD3J8c,48674
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=WWSnM1dgnmKoArOnk8d9lK0dMINylXV1prmmPZHLcmY,8226
37
- cli/resources/rule-set.json,sha256=ACxFGzjZcbJOFB6RoLDhcw0ieQKUqEaTOQWix77shyI,37085
38
- composable_data_stack-0.12.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
39
- composable_data_stack-0.12.0.dist-info/METADATA,sha256=fiklJ9wzQA3Q22UpnV1hIaO27MAxhqjoNaIpbe_aEIA,37127
40
- composable_data_stack-0.12.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
41
- composable_data_stack-0.12.0.dist-info/entry_points.txt,sha256=GBhnbsVzmlnBKLC5Do-tirjz3hTzOfgGXGuZNQxb5ak,38
42
- composable_data_stack-0.12.0.dist-info/top_level.txt,sha256=2ImG917oaVHlm0nP9oJE-Qrgs-fq_fGWgba2H1f8fpE,4
43
- composable_data_stack-0.12.0.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5