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.
- {composable_data_stack-0.12.0/composable_data_stack.egg-info → composable_data_stack-0.13.0}/PKG-INFO +2 -2
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/getter.py +1 -1
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/k8s_renderer.py +13 -1
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/main.py +82 -5
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/preflight.py +148 -7
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/renderer.py +18 -5
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/rule-schema.json +1 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/rule-set.json +14 -11
- composable_data_stack-0.13.0/cli/secret_leak.py +210 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/security.py +137 -1
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0/composable_data_stack.egg-info}/PKG-INFO +2 -2
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/SOURCES.txt +12 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/requires.txt +1 -1
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/pyproject.toml +2 -2
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_compose_runtime_smoke.py +35 -0
- composable_data_stack-0.13.0/tests/test_composer_extra.py +295 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_getter.py +14 -0
- composable_data_stack-0.13.0/tests/test_getter_extra.py +657 -0
- composable_data_stack-0.13.0/tests/test_main_extra.py +446 -0
- composable_data_stack-0.13.0/tests/test_main_extra2.py +540 -0
- composable_data_stack-0.13.0/tests/test_main_extra3.py +647 -0
- composable_data_stack-0.13.0/tests/test_planner_extra.py +525 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_preflight.py +133 -0
- composable_data_stack-0.13.0/tests/test_preflight_extra.py +237 -0
- composable_data_stack-0.13.0/tests/test_secret_file_permissions.py +102 -0
- composable_data_stack-0.13.0/tests/test_secret_output_scanning.py +254 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_security.py +0 -1
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_superset_hardening.py +4 -2
- composable_data_stack-0.13.0/tests/test_update_check_extra.py +102 -0
- composable_data_stack-0.13.0/tests/test_validator_extra.py +559 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/LICENSE +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/README.md +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/__init__.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/audit_log.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/composer.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/constants.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/diagnostics.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/graph.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/image_digest_check.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/image_updates.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/image_verification.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/k8s_runner.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/k8s_security.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/loader.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/overlay.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/planner.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/report.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resolver.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/__init__.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/compatibility-registry.json +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/compatibility-registry.schema.json +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/contract.schema.json +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/module.schema.json +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/resources/profile.schema.json +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/secrets.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/security_common.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/state.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/up_runner.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/update_check.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/utils.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/cli/validator.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/composable_data_stack.egg-info/top_level.txt +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/setup.cfg +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_ai_profile_review.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_audit_log.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_build_cra_release_evidence_manifest.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_bump_changelog.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_cds_workflow.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_check_cra_release_gate.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_check_roadmap_freshness.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_completion.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_compose_to_module.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_composer.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_cra_traceability.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dagster_config_generation.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dagster_definitions_uri.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dagster_hardening.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dbt_hardening.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_dlt_postgres_wiring.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_duckdb_hardening.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_environment_classification.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_environment_cli.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_generate_release_inventory.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_graph.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_digest_check.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_locks.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_security_scan_workflow.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_updates.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_image_verification.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k3d_e2e_profile.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k3d_exposure.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k8s_renderer.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k8s_runner.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_k8s_security.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_keycloak_postgres_wiring.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_loader.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_main.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_markdown_mermaid_blocks.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_module_isolation.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_modules_no_committed_secrets.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_mvp_proof_workflow.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_overlay.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_planner.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_publish_images_workflow.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_python_version_alignment.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_release_notes_support.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_release_version_check.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_release_workflow.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_render_example_profile.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_renderer.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_report.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_resolve_profile_path.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_resolver.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_secrets.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_smoke_example_profile.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_standalone_module_profile.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_state.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_tender_analytics.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_tender_dashboard.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_test_runner_timeout.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_trivyignore.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_up_runner.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_update_check.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_upstream_image_hardening.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_utils.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_validate_cli_sbom.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_validator.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_vault_init_db.py +0 -0
- {composable_data_stack-0.12.0 → composable_data_stack-0.13.0}/tests/test_vault_init_db_runtime.py +0 -0
- {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.
|
|
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)
|
|
@@ -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
|
-
|
|
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,
|
|
@@ -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
|
|
|
@@ -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",
|