composable-data-stack 0.12.0__tar.gz → 0.13.0__tar.gz

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.
Files changed (133) hide show
  1. {composable_data_stack-0.12.0/composable_data_stack.egg-info → composable_data_stack-0.13.0}/PKG-INFO +2 -2
  2. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/getter.py +1 -1
  3. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/k8s_renderer.py +13 -1
  4. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/main.py +82 -5
  5. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/preflight.py +148 -7
  6. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/renderer.py +18 -5
  7. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/rule-schema.json +1 -0
  8. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/rule-set.json +14 -11
  9. composable_data_stack-0.13.0/cli/secret_leak.py +210 -0
  10. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/security.py +137 -1
  11. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0/composable_data_stack.egg-info}/PKG-INFO +2 -2
  12. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/SOURCES.txt +12 -0
  13. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/requires.txt +1 -1
  14. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/pyproject.toml +2 -2
  15. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_compose_runtime_smoke.py +35 -0
  16. composable_data_stack-0.13.0/tests/test_composer_extra.py +295 -0
  17. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_getter.py +14 -0
  18. composable_data_stack-0.13.0/tests/test_getter_extra.py +657 -0
  19. composable_data_stack-0.13.0/tests/test_main_extra.py +446 -0
  20. composable_data_stack-0.13.0/tests/test_main_extra2.py +540 -0
  21. composable_data_stack-0.13.0/tests/test_main_extra3.py +647 -0
  22. composable_data_stack-0.13.0/tests/test_planner_extra.py +525 -0
  23. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_preflight.py +133 -0
  24. composable_data_stack-0.13.0/tests/test_preflight_extra.py +237 -0
  25. composable_data_stack-0.13.0/tests/test_secret_file_permissions.py +102 -0
  26. composable_data_stack-0.13.0/tests/test_secret_output_scanning.py +254 -0
  27. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_security.py +0 -1
  28. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_superset_hardening.py +4 -2
  29. composable_data_stack-0.13.0/tests/test_update_check_extra.py +102 -0
  30. composable_data_stack-0.13.0/tests/test_validator_extra.py +559 -0
  31. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/LICENSE +0 -0
  32. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/README.md +0 -0
  33. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/__init__.py +0 -0
  34. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/audit_log.py +0 -0
  35. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/composer.py +0 -0
  36. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/constants.py +0 -0
  37. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/diagnostics.py +0 -0
  38. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/graph.py +0 -0
  39. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/image_digest_check.py +0 -0
  40. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/image_updates.py +0 -0
  41. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/image_verification.py +0 -0
  42. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/k8s_runner.py +0 -0
  43. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/k8s_security.py +0 -0
  44. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/loader.py +0 -0
  45. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/overlay.py +0 -0
  46. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/planner.py +0 -0
  47. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/report.py +0 -0
  48. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resolver.py +0 -0
  49. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/__init__.py +0 -0
  50. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/compatibility-registry.json +0 -0
  51. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/compatibility-registry.schema.json +0 -0
  52. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/contract.schema.json +0 -0
  53. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/module.schema.json +0 -0
  54. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/profile.schema.json +0 -0
  55. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/secrets.py +0 -0
  56. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/security_common.py +0 -0
  57. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/state.py +0 -0
  58. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/up_runner.py +0 -0
  59. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/update_check.py +0 -0
  60. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/utils.py +0 -0
  61. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/validator.py +0 -0
  62. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  63. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
  64. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/top_level.txt +0 -0
  65. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/setup.cfg +0 -0
  66. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_ai_profile_review.py +0 -0
  67. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_audit_log.py +0 -0
  68. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_build_cra_release_evidence_manifest.py +0 -0
  69. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_bump_changelog.py +0 -0
  70. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_cds_workflow.py +0 -0
  71. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_check_cra_release_gate.py +0 -0
  72. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_check_roadmap_freshness.py +0 -0
  73. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_completion.py +0 -0
  74. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_compose_to_module.py +0 -0
  75. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_composer.py +0 -0
  76. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_cra_traceability.py +0 -0
  77. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dagster_config_generation.py +0 -0
  78. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dagster_definitions_uri.py +0 -0
  79. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
  80. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dagster_hardening.py +0 -0
  81. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dbt_hardening.py +0 -0
  82. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dlt_postgres_wiring.py +0 -0
  83. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_duckdb_hardening.py +0 -0
  84. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_environment_classification.py +0 -0
  85. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_environment_cli.py +0 -0
  86. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_generate_release_inventory.py +0 -0
  87. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_graph.py +0 -0
  88. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_digest_check.py +0 -0
  89. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_locks.py +0 -0
  90. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_security_scan_workflow.py +0 -0
  91. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_updates.py +0 -0
  92. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_verification.py +0 -0
  93. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k3d_e2e_profile.py +0 -0
  94. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k3d_exposure.py +0 -0
  95. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k8s_renderer.py +0 -0
  96. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k8s_runner.py +0 -0
  97. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k8s_security.py +0 -0
  98. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_keycloak_postgres_wiring.py +0 -0
  99. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_loader.py +0 -0
  100. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_main.py +0 -0
  101. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_markdown_mermaid_blocks.py +0 -0
  102. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_module_isolation.py +0 -0
  103. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_modules_no_committed_secrets.py +0 -0
  104. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_mvp_proof_workflow.py +0 -0
  105. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_overlay.py +0 -0
  106. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_planner.py +0 -0
  107. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_publish_images_workflow.py +0 -0
  108. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_python_version_alignment.py +0 -0
  109. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_release_notes_support.py +0 -0
  110. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_release_version_check.py +0 -0
  111. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_release_workflow.py +0 -0
  112. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_render_example_profile.py +0 -0
  113. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_renderer.py +0 -0
  114. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_report.py +0 -0
  115. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_resolve_profile_path.py +0 -0
  116. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_resolver.py +0 -0
  117. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_secrets.py +0 -0
  118. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_smoke_example_profile.py +0 -0
  119. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_standalone_module_profile.py +0 -0
  120. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_state.py +0 -0
  121. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_tender_analytics.py +0 -0
  122. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_tender_dashboard.py +0 -0
  123. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_test_runner_timeout.py +0 -0
  124. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_trivyignore.py +0 -0
  125. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_up_runner.py +0 -0
  126. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_update_check.py +0 -0
  127. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_upstream_image_hardening.py +0 -0
  128. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_utils.py +0 -0
  129. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_validate_cli_sbom.py +0 -0
  130. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_validator.py +0 -0
  131. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_vault_init_db.py +0 -0
  132. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_vault_init_db_runtime.py +0 -0
  133. {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_vendored_llm_client.py +0 -0
@@ -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)
@@ -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:
@@ -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
@@ -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)
@@ -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,
@@ -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",