composable-data-stack 0.12.0__py3-none-any.whl → 0.14.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/confinement.py +219 -0
- cli/getter.py +1 -1
- cli/k8s_renderer.py +75 -5
- cli/main.py +82 -5
- cli/planner.py +69 -0
- cli/preflight.py +153 -9
- cli/renderer.py +503 -16
- cli/resolver.py +19 -0
- cli/resources/module.schema.json +5 -0
- cli/resources/profile.schema.json +151 -0
- cli/resources/rule-schema.json +1 -0
- cli/resources/rule-set.json +64 -11
- cli/secret_leak.py +210 -0
- cli/security.py +310 -2
- cli/validator.py +691 -0
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.14.0.dist-info}/METADATA +16 -2
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.14.0.dist-info}/RECORD +21 -19
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.14.0.dist-info}/WHEEL +1 -1
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.14.0.dist-info}/entry_points.txt +0 -0
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.14.0.dist-info}/licenses/LICENSE +0 -0
- {composable_data_stack-0.12.0.dist-info → composable_data_stack-0.14.0.dist-info}/top_level.txt +0 -0
cli/confinement.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Declarative runtime confinement (seccomp, AppArmor, SELinux) for Compose services.
|
|
2
|
+
|
|
3
|
+
A profile declares a default under `spec.security.runtime.confinement`; a
|
|
4
|
+
module instance may override individual keys under `security.confinement`.
|
|
5
|
+
The effective value is rendered into each service's `security_opt`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .diagnostics import Diagnostic
|
|
15
|
+
|
|
16
|
+
INVALID_CONFINEMENT_CODE = "E127"
|
|
17
|
+
CONFINEMENT_KEYS = ("seccomp", "apparmor", "selinux")
|
|
18
|
+
|
|
19
|
+
_APPARMOR_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
|
|
20
|
+
_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
|
21
|
+
_SELINUX_LABEL_RE = re.compile(r"^(user|role|type|level):[A-Za-z0-9_.,:-]+$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def profile_confinement(profile_or_plan: dict[str, Any] | None) -> dict[str, Any]:
|
|
25
|
+
"""Return the profile-wide confinement block from a profile or plan."""
|
|
26
|
+
if not isinstance(profile_or_plan, dict):
|
|
27
|
+
return {}
|
|
28
|
+
security = profile_or_plan.get("security")
|
|
29
|
+
if security is None:
|
|
30
|
+
security = (profile_or_plan.get("spec") or {}).get("security")
|
|
31
|
+
runtime = (security or {}).get("runtime") if isinstance(security, dict) else None
|
|
32
|
+
block = (runtime or {}).get("confinement") if isinstance(runtime, dict) else None
|
|
33
|
+
return block if isinstance(block, dict) else {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def plan_declares_confinement(plan: dict[str, Any] | None) -> bool:
|
|
37
|
+
"""True when the profile or any module instance sets confinement."""
|
|
38
|
+
if profile_confinement(plan):
|
|
39
|
+
return True
|
|
40
|
+
return any(
|
|
41
|
+
isinstance((module.get("security") or {}).get("confinement"), dict)
|
|
42
|
+
and (module.get("security") or {}).get("confinement")
|
|
43
|
+
for module in (plan or {}).get("modules") or []
|
|
44
|
+
if isinstance(module, dict)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def effective_confinement(
|
|
49
|
+
defaults: dict[str, Any] | None, override: dict[str, Any] | None
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Profile defaults with the module instance's keys layered on top."""
|
|
52
|
+
merged = {key: value for key, value in (defaults or {}).items() if key in CONFINEMENT_KEYS}
|
|
53
|
+
for key, value in (override or {}).items():
|
|
54
|
+
if key in CONFINEMENT_KEYS:
|
|
55
|
+
merged[key] = value
|
|
56
|
+
return merged
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_confinement(
|
|
60
|
+
block: Any, path: str, base_dir: Path | None = None
|
|
61
|
+
) -> list[Diagnostic]:
|
|
62
|
+
"""Check one confinement block; `base_dir` anchors `file:` seccomp profiles."""
|
|
63
|
+
if block is None:
|
|
64
|
+
return []
|
|
65
|
+
if not isinstance(block, dict):
|
|
66
|
+
return [_error(f"{path} must be an object.", path)]
|
|
67
|
+
diagnostics: list[Diagnostic] = []
|
|
68
|
+
for key in block:
|
|
69
|
+
if key not in CONFINEMENT_KEYS:
|
|
70
|
+
diagnostics.append(_error(
|
|
71
|
+
f"Unknown confinement key '{key}'; supported keys: {', '.join(CONFINEMENT_KEYS)}.",
|
|
72
|
+
f"{path}.{key}",
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
seccomp = block.get("seccomp")
|
|
76
|
+
if seccomp is not None:
|
|
77
|
+
diagnostics.extend(_validate_seccomp(seccomp, f"{path}.seccomp", base_dir))
|
|
78
|
+
apparmor = block.get("apparmor")
|
|
79
|
+
if apparmor is not None and not (
|
|
80
|
+
isinstance(apparmor, str)
|
|
81
|
+
and (apparmor in {"runtime/default", "unconfined"} or _APPARMOR_NAME_RE.match(apparmor))
|
|
82
|
+
):
|
|
83
|
+
diagnostics.append(_error(
|
|
84
|
+
"apparmor must be 'runtime/default', 'unconfined' or the name of a profile "
|
|
85
|
+
"already loaded on the host (letters, digits, '.', '_', '-', '/').",
|
|
86
|
+
f"{path}.apparmor",
|
|
87
|
+
))
|
|
88
|
+
selinux = block.get("selinux")
|
|
89
|
+
if selinux is not None:
|
|
90
|
+
diagnostics.extend(_validate_selinux(selinux, f"{path}.selinux"))
|
|
91
|
+
return diagnostics
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _validate_seccomp(value: Any, path: str, base_dir: Path | None) -> list[Diagnostic]:
|
|
95
|
+
if value in {"default", "unconfined"}:
|
|
96
|
+
return []
|
|
97
|
+
if not isinstance(value, str) or not value.startswith("file:") or len(value) == 5:
|
|
98
|
+
return [_error(
|
|
99
|
+
"seccomp must be 'default', 'unconfined' or 'file:<path to a JSON profile "
|
|
100
|
+
"relative to the profile directory>'.",
|
|
101
|
+
path,
|
|
102
|
+
)]
|
|
103
|
+
relative = value[5:]
|
|
104
|
+
if base_dir is None:
|
|
105
|
+
return [_error(
|
|
106
|
+
"seccomp file profiles need a known profile directory to resolve against.", path
|
|
107
|
+
)]
|
|
108
|
+
# Reject rooted and drive-qualified paths explicitly: they are not
|
|
109
|
+
# "absolute" to pathlib on every platform (e.g. "/x" on Windows).
|
|
110
|
+
if (
|
|
111
|
+
Path(relative).is_absolute()
|
|
112
|
+
or relative.startswith(("/", "\\"))
|
|
113
|
+
or _DRIVE_RE.match(relative)
|
|
114
|
+
or ".." in Path(relative).parts
|
|
115
|
+
):
|
|
116
|
+
return [_error(
|
|
117
|
+
"seccomp file profiles must be relative paths inside the profile directory.", path
|
|
118
|
+
)]
|
|
119
|
+
target = (base_dir / relative).resolve()
|
|
120
|
+
if not target.is_relative_to(base_dir.resolve()):
|
|
121
|
+
return [_error(
|
|
122
|
+
"seccomp file profile resolves outside the profile directory.", path
|
|
123
|
+
)]
|
|
124
|
+
if not target.is_file():
|
|
125
|
+
return [_error(f"seccomp profile '{relative}' does not exist.", path)]
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _validate_selinux(value: Any, path: str) -> list[Diagnostic]:
|
|
130
|
+
if value == "disable":
|
|
131
|
+
return []
|
|
132
|
+
if not isinstance(value, list) or not value or not all(isinstance(v, str) for v in value):
|
|
133
|
+
return [_error(
|
|
134
|
+
"selinux must be 'disable' or a non-empty list of labels such as "
|
|
135
|
+
"'type:container_t' or 'level:s0:c100,c200'.",
|
|
136
|
+
path,
|
|
137
|
+
)]
|
|
138
|
+
bad = [label for label in value if not _SELINUX_LABEL_RE.match(label)]
|
|
139
|
+
if bad:
|
|
140
|
+
return [_error(
|
|
141
|
+
f"Invalid SELinux label(s): {', '.join(bad)}; expected user:, role:, type: or level: values.",
|
|
142
|
+
path,
|
|
143
|
+
)]
|
|
144
|
+
kinds = [label.split(":", 1)[0] for label in value]
|
|
145
|
+
duplicates = sorted({k for k in kinds if kinds.count(k) > 1})
|
|
146
|
+
if duplicates:
|
|
147
|
+
return [_error(
|
|
148
|
+
f"Duplicate SELinux label kind(s): {', '.join(duplicates)}; "
|
|
149
|
+
"each of user:, role:, type: and level: may appear at most once.",
|
|
150
|
+
path,
|
|
151
|
+
)]
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def confinement_security_opts(
|
|
156
|
+
confinement: dict[str, Any], compose_dir: Path, base_dir: Path | None
|
|
157
|
+
) -> list[str]:
|
|
158
|
+
"""Translate a validated confinement block into Compose `security_opt` entries.
|
|
159
|
+
|
|
160
|
+
Raises ValueError when a seccomp file cannot be expressed relative to
|
|
161
|
+
`compose_dir` (for example on a different Windows drive).
|
|
162
|
+
"""
|
|
163
|
+
opts: list[str] = []
|
|
164
|
+
seccomp = confinement.get("seccomp")
|
|
165
|
+
if seccomp == "unconfined":
|
|
166
|
+
opts.append("seccomp=unconfined")
|
|
167
|
+
elif isinstance(seccomp, str) and seccomp.startswith("file:"):
|
|
168
|
+
target = (base_dir or compose_dir) / seccomp[5:]
|
|
169
|
+
opts.append(f"seccomp:{Path(os.path.relpath(target, compose_dir)).as_posix()}")
|
|
170
|
+
apparmor = confinement.get("apparmor")
|
|
171
|
+
if apparmor == "runtime/default":
|
|
172
|
+
opts.append("apparmor=docker-default")
|
|
173
|
+
elif isinstance(apparmor, str):
|
|
174
|
+
opts.append(f"apparmor={apparmor}")
|
|
175
|
+
selinux = confinement.get("selinux")
|
|
176
|
+
if selinux == "disable":
|
|
177
|
+
opts.append("label=disable")
|
|
178
|
+
elif isinstance(selinux, list):
|
|
179
|
+
opts.extend(f"label={label}" for label in selinux)
|
|
180
|
+
return opts
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def apply_confinement(
|
|
184
|
+
service: dict[str, Any],
|
|
185
|
+
confinement: dict[str, Any],
|
|
186
|
+
compose_dir: Path,
|
|
187
|
+
base_dir: Path | None,
|
|
188
|
+
path: str = "security.confinement",
|
|
189
|
+
) -> list[Diagnostic]:
|
|
190
|
+
"""Set the declared confinement on a service, replacing module-set values per key."""
|
|
191
|
+
if not confinement:
|
|
192
|
+
return []
|
|
193
|
+
try:
|
|
194
|
+
new_opts = confinement_security_opts(confinement, compose_dir, base_dir)
|
|
195
|
+
except ValueError:
|
|
196
|
+
return [_error(
|
|
197
|
+
"seccomp file profile cannot be referenced relative to the generated "
|
|
198
|
+
"docker-compose.yml (different drive); render into the profile's drive.",
|
|
199
|
+
f"{path}.seccomp",
|
|
200
|
+
)]
|
|
201
|
+
existing = [str(opt) for opt in service.get("security_opt") or []]
|
|
202
|
+
replaced = {
|
|
203
|
+
"seccomp": ("seccomp=", "seccomp:"),
|
|
204
|
+
"apparmor": ("apparmor=", "apparmor:"),
|
|
205
|
+
"selinux": ("label=", "label:"),
|
|
206
|
+
}
|
|
207
|
+
prefixes = tuple(p for key in confinement for p in replaced.get(key, ()))
|
|
208
|
+
kept = [opt for opt in existing if not opt.startswith(prefixes)] if prefixes else existing
|
|
209
|
+
# `default` seccomp means "engine default", so it only clears module-set values.
|
|
210
|
+
merged = kept + new_opts
|
|
211
|
+
if merged:
|
|
212
|
+
service["security_opt"] = merged
|
|
213
|
+
else:
|
|
214
|
+
service.pop("security_opt", None)
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _error(message: str, path: str) -> Diagnostic:
|
|
219
|
+
return Diagnostic(level="error", code=INVALID_CONFINEMENT_CODE, message=message, path=path)
|
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
|
@@ -28,6 +28,7 @@ from typing import Any
|
|
|
28
28
|
|
|
29
29
|
import yaml
|
|
30
30
|
|
|
31
|
+
from .confinement import plan_declares_confinement
|
|
31
32
|
from .diagnostics import Diagnostic
|
|
32
33
|
from .renderer import (
|
|
33
34
|
_build_context,
|
|
@@ -37,6 +38,12 @@ from .renderer import (
|
|
|
37
38
|
_resolve_profile_dir,
|
|
38
39
|
_resolve_project_root,
|
|
39
40
|
_substitute_values,
|
|
41
|
+
apply_production_gates,
|
|
42
|
+
)
|
|
43
|
+
from .secret_leak import (
|
|
44
|
+
check_generated_text,
|
|
45
|
+
declared_secret_env_names,
|
|
46
|
+
resolved_secret_values,
|
|
40
47
|
)
|
|
41
48
|
from .utils import _atomic_write
|
|
42
49
|
|
|
@@ -73,12 +80,14 @@ class K8sRenderError(Exception):
|
|
|
73
80
|
def render_helm(
|
|
74
81
|
plan: dict[str, Any],
|
|
75
82
|
output_dir: str | None = None,
|
|
83
|
+
env_file: str | None = None,
|
|
76
84
|
) -> tuple[dict[str, str], list[Diagnostic]]:
|
|
77
85
|
"""
|
|
78
86
|
Render a Helm chart from a resolved plan.
|
|
79
87
|
|
|
80
88
|
Returns a mapping of chart-relative path -> file contents, plus diagnostics.
|
|
81
|
-
Files are written to `output_dir` when given
|
|
89
|
+
Files are written to `output_dir` when given, unless an error was found
|
|
90
|
+
(including a resolved secret value in any chart file, CDS-SEC-030).
|
|
82
91
|
"""
|
|
83
92
|
diagnostics: list[Diagnostic] = []
|
|
84
93
|
metadata = plan.get("metadata", {})
|
|
@@ -103,6 +112,21 @@ def render_helm(
|
|
|
103
112
|
path=f"module:{module_id}.implementation.kubernetes",
|
|
104
113
|
)
|
|
105
114
|
)
|
|
115
|
+
|
|
116
|
+
if plan_declares_confinement(plan):
|
|
117
|
+
diagnostics.append(
|
|
118
|
+
Diagnostic(
|
|
119
|
+
level="warning",
|
|
120
|
+
code="W127",
|
|
121
|
+
message=(
|
|
122
|
+
"Runtime confinement (seccomp/AppArmor/SELinux) is only rendered for "
|
|
123
|
+
"the compose target; it has no effect on the Helm chart. "
|
|
124
|
+
"Kubernetes security contexts are tracked under #65."
|
|
125
|
+
),
|
|
126
|
+
path="spec.security.runtime.confinement",
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
106
130
|
if unsupported:
|
|
107
131
|
return {}, diagnostics
|
|
108
132
|
|
|
@@ -110,6 +134,28 @@ def render_helm(
|
|
|
110
134
|
# ${k8s.service.*} can point forward to a workload declared later.
|
|
111
135
|
service_names = _collect_service_names(renderable)
|
|
112
136
|
diagnostics.extend(_check_binding_services(renderable, service_names))
|
|
137
|
+
|
|
138
|
+
# The built-in backup capability (config.backup.enabled, issue #665) is
|
|
139
|
+
# only implemented for the compose target: there is no Kubernetes
|
|
140
|
+
# sidecar workload yet. Fail closed instead of rendering a chart whose
|
|
141
|
+
# database is silently unprotected.
|
|
142
|
+
for module in renderable:
|
|
143
|
+
backup_config = (module.get("config") or {}).get("backup")
|
|
144
|
+
if isinstance(backup_config, dict) and backup_config.get("enabled") is True:
|
|
145
|
+
diagnostics.append(
|
|
146
|
+
Diagnostic(
|
|
147
|
+
level="error",
|
|
148
|
+
code="E142",
|
|
149
|
+
message=(
|
|
150
|
+
f'Module "{module["id"]}" enables config.backup.enabled, but the '
|
|
151
|
+
'backup capability is not implemented for the "helm" render target '
|
|
152
|
+
"(no Kubernetes sidecar workload exists yet). Render this profile "
|
|
153
|
+
"with --target=compose, or disable backup for Kubernetes deploys."
|
|
154
|
+
),
|
|
155
|
+
path=f"module:{module['id']}.config.backup.enabled",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
113
159
|
if any(diagnostic.level == "error" for diagnostic in diagnostics):
|
|
114
160
|
return {}, diagnostics
|
|
115
161
|
|
|
@@ -134,8 +180,13 @@ def render_helm(
|
|
|
134
180
|
"service": _service_context(module, service_names)
|
|
135
181
|
}
|
|
136
182
|
_release_scope_binding_hosts(context, service_names)
|
|
137
|
-
|
|
138
|
-
|
|
183
|
+
# The helm target is treated as production-only for now: Kubernetes
|
|
184
|
+
# deploys always render the production variants, regardless of the
|
|
185
|
+
# profile's metadata.environment. The learning (easy-install)
|
|
186
|
+
# variants exist for the compose target.
|
|
187
|
+
compose_services = apply_production_gates(
|
|
188
|
+
deepcopy(module["implementation"].get("compose", {}).get("services", {})),
|
|
189
|
+
True,
|
|
139
190
|
)
|
|
140
191
|
_merge_init_db_env(compose_services, module, secrets)
|
|
141
192
|
|
|
@@ -155,6 +206,7 @@ def render_helm(
|
|
|
155
206
|
service_names=service_names,
|
|
156
207
|
profile_dir=profile_dir,
|
|
157
208
|
project_root=project_root,
|
|
209
|
+
is_production=True,
|
|
158
210
|
)
|
|
159
211
|
values["modules"][module_id] = {
|
|
160
212
|
"config": _values_config(module.get("config", {})),
|
|
@@ -183,6 +235,11 @@ def render_helm(
|
|
|
183
235
|
|
|
184
236
|
diagnostics.extend(_check_unresolved(files))
|
|
185
237
|
diagnostics.extend(_check_secret_references(files, secret_keys))
|
|
238
|
+
secret_values = resolved_secret_values(declared_secret_env_names(plan), env_file)
|
|
239
|
+
for relative_path, content in files.items():
|
|
240
|
+
diagnostics.extend(check_generated_text(
|
|
241
|
+
content, f"{output_dir or 'chart'}/{relative_path}", secret_values
|
|
242
|
+
))
|
|
186
243
|
|
|
187
244
|
if output_dir and not any(
|
|
188
245
|
diagnostic.level == "error" for diagnostic in diagnostics
|
|
@@ -207,6 +264,7 @@ def _render_module(
|
|
|
207
264
|
service_names: dict[str, str],
|
|
208
265
|
profile_dir: Path | None,
|
|
209
266
|
project_root: Path | None,
|
|
267
|
+
is_production: bool = False,
|
|
210
268
|
) -> tuple[
|
|
211
269
|
dict[str, Any],
|
|
212
270
|
list[tuple[str, dict[str, Any]]],
|
|
@@ -220,12 +278,16 @@ def _render_module(
|
|
|
220
278
|
secret_keys: set[str] = set()
|
|
221
279
|
module_values: dict[str, Any] = {}
|
|
222
280
|
|
|
223
|
-
volume_specs =
|
|
281
|
+
volume_specs = apply_production_gates(
|
|
282
|
+
_substitute_values(deepcopy(k8s.get("volumes", {})), context), is_production
|
|
283
|
+
)
|
|
284
|
+
configmap_specs = apply_production_gates(
|
|
285
|
+
_substitute_values(deepcopy(k8s.get("configMaps", {})), context), is_production
|
|
286
|
+
)
|
|
224
287
|
for volume_spec in volume_specs.values():
|
|
225
288
|
enabled_from = volume_spec.get("enabledFrom")
|
|
226
289
|
if isinstance(enabled_from, str):
|
|
227
290
|
volume_spec["enabledFrom"] = _resolve_expr(enabled_from, context)
|
|
228
|
-
configmap_specs = _substitute_values(deepcopy(k8s.get("configMaps", {})), context)
|
|
229
291
|
|
|
230
292
|
# ConfigMaps are module-scoped: one manifest, mounted by whichever
|
|
231
293
|
# containers name it.
|
|
@@ -293,6 +355,7 @@ def _render_module(
|
|
|
293
355
|
pod_volumes,
|
|
294
356
|
pvcs,
|
|
295
357
|
is_init=True,
|
|
358
|
+
is_production=is_production,
|
|
296
359
|
)
|
|
297
360
|
secret_keys |= keys
|
|
298
361
|
init_containers.append(container)
|
|
@@ -316,6 +379,7 @@ def _render_module(
|
|
|
316
379
|
pod_volumes,
|
|
317
380
|
pvcs,
|
|
318
381
|
is_init=False,
|
|
382
|
+
is_production=is_production,
|
|
319
383
|
)
|
|
320
384
|
secret_keys |= keys
|
|
321
385
|
containers.append(container)
|
|
@@ -420,6 +484,7 @@ def _translate_container(
|
|
|
420
484
|
pod_volumes: dict[str, dict[str, Any]],
|
|
421
485
|
pvcs: dict[str, dict[str, Any]],
|
|
422
486
|
is_init: bool,
|
|
487
|
+
is_production: bool = False,
|
|
423
488
|
) -> tuple[dict[str, Any], dict, dict, set[str]]:
|
|
424
489
|
spec = _substitute_values(deepcopy(spec), context)
|
|
425
490
|
spec.pop("enabledFrom", None)
|
|
@@ -483,6 +548,7 @@ def _translate_container(
|
|
|
483
548
|
pvcs,
|
|
484
549
|
overrides.get("dropVolumeMounts", []) or [],
|
|
485
550
|
context=context,
|
|
551
|
+
is_production=is_production,
|
|
486
552
|
)
|
|
487
553
|
if mounts:
|
|
488
554
|
container["volumeMounts"] = mounts
|
|
@@ -718,6 +784,7 @@ def _translate_volumes(
|
|
|
718
784
|
pvcs: dict[str, dict[str, Any]],
|
|
719
785
|
drop_mounts: list[str],
|
|
720
786
|
context: dict[str, Any] | None = None,
|
|
787
|
+
is_production: bool = False,
|
|
721
788
|
) -> list[dict[str, Any]]:
|
|
722
789
|
mounts: list[dict[str, Any]] = []
|
|
723
790
|
dropped = {str(d).rstrip("/") for d in drop_mounts}
|
|
@@ -733,6 +800,9 @@ def _translate_volumes(
|
|
|
733
800
|
mounts.append({"name": name, "mountPath": path})
|
|
734
801
|
|
|
735
802
|
for entry in spec.get("volumes", []) or []:
|
|
803
|
+
if isinstance(entry, dict) and isinstance(entry.get("production"), bool):
|
|
804
|
+
if entry["production"] != is_production:
|
|
805
|
+
continue
|
|
736
806
|
source, target, read_only, kind = _parse_volume_entry(entry)
|
|
737
807
|
if target is None or target.rstrip("/") in dropped:
|
|
738
808
|
continue
|
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)
|