composable-data-stack 0.9.1__tar.gz → 0.10.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 (105) hide show
  1. {composable_data_stack-0.9.1/composable_data_stack.egg-info → composable_data_stack-0.10.0}/PKG-INFO +4 -2
  2. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/README.md +2 -0
  3. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/image_verification.py +12 -0
  4. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/k8s_security.py +13 -0
  5. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/main.py +136 -12
  6. composable_data_stack-0.10.0/cli/resources/compatibility-registry.json +31 -0
  7. composable_data_stack-0.10.0/cli/resources/compatibility-registry.schema.json +70 -0
  8. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/resources/profile.schema.json +28 -0
  9. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/resources/rule-schema.json +24 -0
  10. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/resources/rule-set.json +55 -0
  11. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/security.py +317 -18
  12. composable_data_stack-0.10.0/cli/security_common.py +101 -0
  13. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/validator.py +108 -0
  14. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0/composable_data_stack.egg-info}/PKG-INFO +4 -2
  15. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/composable_data_stack.egg-info/SOURCES.txt +7 -0
  16. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/composable_data_stack.egg-info/requires.txt +1 -1
  17. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/pyproject.toml +2 -2
  18. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_cds_workflow.py +85 -0
  19. composable_data_stack-0.10.0/tests/test_check_roadmap_freshness.py +203 -0
  20. composable_data_stack-0.10.0/tests/test_generate_release_inventory.py +197 -0
  21. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_image_verification.py +13 -0
  22. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_k8s_security.py +28 -0
  23. composable_data_stack-0.10.0/tests/test_keycloak_postgres_wiring.py +169 -0
  24. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_main.py +225 -1
  25. composable_data_stack-0.10.0/tests/test_release_notes_support.py +79 -0
  26. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_security.py +452 -1
  27. composable_data_stack-0.10.0/tests/test_validate_cli_sbom.py +145 -0
  28. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_validator.py +172 -1
  29. composable_data_stack-0.9.1/cli/security_common.py +0 -40
  30. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/LICENSE +0 -0
  31. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/__init__.py +0 -0
  32. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/constants.py +0 -0
  33. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/diagnostics.py +0 -0
  34. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/getter.py +0 -0
  35. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/graph.py +0 -0
  36. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/image_updates.py +0 -0
  37. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/k8s_renderer.py +0 -0
  38. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/k8s_runner.py +0 -0
  39. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/loader.py +0 -0
  40. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/overlay.py +0 -0
  41. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/planner.py +0 -0
  42. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/preflight.py +0 -0
  43. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/renderer.py +0 -0
  44. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/resolver.py +0 -0
  45. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/resources/__init__.py +0 -0
  46. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/resources/contract.schema.json +0 -0
  47. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/resources/module.schema.json +0 -0
  48. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/secrets.py +0 -0
  49. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/state.py +0 -0
  50. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/up_runner.py +0 -0
  51. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/cli/utils.py +0 -0
  52. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  53. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
  54. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/composable_data_stack.egg-info/top_level.txt +0 -0
  55. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/setup.cfg +0 -0
  56. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_ai_profile_review.py +0 -0
  57. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_bump_changelog.py +0 -0
  58. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_completion.py +0 -0
  59. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_compose_runtime_smoke.py +0 -0
  60. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_compose_to_module.py +0 -0
  61. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_dagster_config_generation.py +0 -0
  62. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_dagster_definitions_uri.py +0 -0
  63. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
  64. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_dagster_hardening.py +0 -0
  65. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_dbt_hardening.py +0 -0
  66. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_dlt_postgres_wiring.py +0 -0
  67. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_duckdb_hardening.py +0 -0
  68. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_environment_classification.py +0 -0
  69. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_environment_cli.py +0 -0
  70. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_getter.py +0 -0
  71. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_graph.py +0 -0
  72. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_image_security_scan_workflow.py +0 -0
  73. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_image_updates.py +0 -0
  74. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_k3d_e2e_profile.py +0 -0
  75. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_k3d_exposure.py +0 -0
  76. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_k8s_renderer.py +0 -0
  77. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_k8s_runner.py +0 -0
  78. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_loader.py +0 -0
  79. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_markdown_mermaid_blocks.py +0 -0
  80. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_module_isolation.py +0 -0
  81. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_modules_no_committed_secrets.py +0 -0
  82. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_mvp_proof_workflow.py +0 -0
  83. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_overlay.py +0 -0
  84. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_planner.py +0 -0
  85. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_preflight.py +0 -0
  86. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_publish_images_workflow.py +0 -0
  87. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_python_version_alignment.py +0 -0
  88. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_release_version_check.py +0 -0
  89. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_render_example_profile.py +0 -0
  90. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_renderer.py +0 -0
  91. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_resolve_profile_path.py +0 -0
  92. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_resolver.py +0 -0
  93. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_secrets.py +0 -0
  94. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_smoke_example_profile.py +0 -0
  95. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_standalone_module_profile.py +0 -0
  96. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_state.py +0 -0
  97. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_superset_hardening.py +0 -0
  98. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_tender_analytics.py +0 -0
  99. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_tender_dashboard.py +0 -0
  100. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_test_runner_timeout.py +0 -0
  101. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_trivyignore.py +0 -0
  102. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_up_runner.py +0 -0
  103. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_upstream_image_hardening.py +0 -0
  104. {composable_data_stack-0.9.1 → composable_data_stack-0.10.0}/tests/test_utils.py +0 -0
  105. {composable_data_stack-0.9.1 → composable_data_stack-0.10.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.9.1
3
+ Version: 0.10.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.16.8; extra == "dev"
35
+ Requires-Dist: ruff==0.16.9; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
38
  # 🚀 Composable Data Stack (CDS)
@@ -892,6 +892,7 @@ Common errors from `cds validate`, `cds plan`, and `cds render`, and how to fix
892
892
  | `[E041] ... Contract ref "x.y" points to unknown module "x"` | A `consumes` binding's `contractRef` refers to a module ID that isn't defined in the profile. | Check `spec.modules` for the correct module `id`, and confirm the contract ref follows `<module-id>.<contract-name>`. |
893
893
  | `[E041] ... but it does not provide "<contract-name>"` | The referenced module exists, but its `spec.provides` list doesn't expose that contract name. | Check the producing module's `module.yaml` for the contracts it actually provides, and fix the consumer's `contractRef` to match. |
894
894
  | `[E042] ... Contract kind mismatch` | The consumer expects one contract kind (e.g. `sql-database`) but the producer exposes a different kind. | Point the binding at a module that provides the expected contract kind, or update the consumer's expected kind if the mismatch is intentional. |
895
+ | `[E043] ... recorded as unsupported in the compatibility registry` | The binding's `kind` matches, but this exact provider/consumer pairing is explicitly recorded as `unsupported` in `cli/resources/compatibility-registry.json` -- it's known NOT to work together despite matching shape. | Use a different provider/consumer pairing for this contract, or if the pairing has since been fixed/verified, update its `status` to `tested` in the registry (see [docs/architecture.md § Secrets and contract resolution](docs/architecture.md#secrets-and-contract-resolution)). |
895
896
  | `[E103] ... config.image.tag is required ... when config.image.source is "registry"` | A module instance sets `config.image.source: registry` without also setting `config.image.tag`. | Set `config.image.tag` to a version, or switch back to `config.image.source: build`. |
896
897
  | `[W097] ... config.image.tag is "latest" with config.image.source "registry"` | `config.image.tag: latest` under `source: registry` still validates, but drifts silently between deploys instead of pinning a reproducible version. | Pin an explicit tag from `publish-images.yml`'s output (or `tests/fixtures/signed-images.json`) instead of `latest`. |
897
898
 
@@ -1036,6 +1037,7 @@ Please read these first:
1036
1037
  - [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
1037
1038
  - [SECURITY.md](SECURITY.md)
1038
1039
  - [SUPPORT.md](SUPPORT.md)
1040
+ - [docs/security-support-policy.md](docs/security-support-policy.md)
1039
1041
  - [CHANGELOG.md](CHANGELOG.md)
1040
1042
  - [RELEASE.md](RELEASE.md)
1041
1043
 
@@ -855,6 +855,7 @@ Common errors from `cds validate`, `cds plan`, and `cds render`, and how to fix
855
855
  | `[E041] ... Contract ref "x.y" points to unknown module "x"` | A `consumes` binding's `contractRef` refers to a module ID that isn't defined in the profile. | Check `spec.modules` for the correct module `id`, and confirm the contract ref follows `<module-id>.<contract-name>`. |
856
856
  | `[E041] ... but it does not provide "<contract-name>"` | The referenced module exists, but its `spec.provides` list doesn't expose that contract name. | Check the producing module's `module.yaml` for the contracts it actually provides, and fix the consumer's `contractRef` to match. |
857
857
  | `[E042] ... Contract kind mismatch` | The consumer expects one contract kind (e.g. `sql-database`) but the producer exposes a different kind. | Point the binding at a module that provides the expected contract kind, or update the consumer's expected kind if the mismatch is intentional. |
858
+ | `[E043] ... recorded as unsupported in the compatibility registry` | The binding's `kind` matches, but this exact provider/consumer pairing is explicitly recorded as `unsupported` in `cli/resources/compatibility-registry.json` -- it's known NOT to work together despite matching shape. | Use a different provider/consumer pairing for this contract, or if the pairing has since been fixed/verified, update its `status` to `tested` in the registry (see [docs/architecture.md § Secrets and contract resolution](docs/architecture.md#secrets-and-contract-resolution)). |
858
859
  | `[E103] ... config.image.tag is required ... when config.image.source is "registry"` | A module instance sets `config.image.source: registry` without also setting `config.image.tag`. | Set `config.image.tag` to a version, or switch back to `config.image.source: build`. |
859
860
  | `[W097] ... config.image.tag is "latest" with config.image.source "registry"` | `config.image.tag: latest` under `source: registry` still validates, but drifts silently between deploys instead of pinning a reproducible version. | Pin an explicit tag from `publish-images.yml`'s output (or `tests/fixtures/signed-images.json`) instead of `latest`. |
860
861
 
@@ -999,6 +1000,7 @@ Please read these first:
999
1000
  - [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
1000
1001
  - [SECURITY.md](SECURITY.md)
1001
1002
  - [SUPPORT.md](SUPPORT.md)
1003
+ - [docs/security-support-policy.md](docs/security-support-policy.md)
1002
1004
  - [CHANGELOG.md](CHANGELOG.md)
1003
1005
  - [RELEASE.md](RELEASE.md)
1004
1006
 
@@ -156,6 +156,18 @@ def _finding(
156
156
  return {
157
157
  "rule_id": rule_id,
158
158
  "severity": severity,
159
+ # These findings aren't declared in cli/resources/rule-set.json (see
160
+ # docs/image-signing.md for why), so their compliance category can't
161
+ # be looked up the way cli.security.run_security_validation() does
162
+ # for rule-set findings. Every check here is about image tag/digest
163
+ # pinning, registry trust, or signature/provenance verification --
164
+ # i.e. keeping deployed images current, authentic, and traceable to
165
+ # a trusted build -- which is exactly the use case
166
+ # docs/security-compliance-categories.md documents "patching" for,
167
+ # so tag them directly with that
168
+ # cli.security_common.COMPLIANCE_CATEGORIES value instead of
169
+ # leaving them uncategorized.
170
+ "category": "patching",
159
171
  "module": service,
160
172
  "message": message,
161
173
  "path": f"services.{service}.image",
@@ -76,6 +76,7 @@ def scan_k8s_security(plan: dict[str, Any]) -> list[dict[str, Any]]:
76
76
  f"{path}.resources.{container_name}",
77
77
  module_id,
78
78
  ["Declare both requests and limits for the container."],
79
+ category="configuration-management",
79
80
  ))
80
81
  return findings
81
82
 
@@ -113,10 +114,22 @@ def _finding(
113
114
  path: str,
114
115
  module: str,
115
116
  recommendation: list[str],
117
+ *,
118
+ category: str = "system-hardening",
116
119
  ) -> dict[str, Any]:
117
120
  return {
118
121
  "rule_id": rule_id,
119
122
  "severity": severity,
123
+ # These findings aren't declared in cli/resources/rule-set.json (the
124
+ # declarative rule-set engine has no scope for the plan's
125
+ # Kubernetes-specific implementation), so their compliance category
126
+ # can't be looked up the way cli.security.run_security_validation()
127
+ # does for rule-set findings. CDS-K8S-001-004 are container/pod
128
+ # runtime-hardening posture checks, so they default to the matching
129
+ # cli.security_common.COMPLIANCE_CATEGORIES value; CDS-K8S-005
130
+ # (resource requests/limits) overrides this below since it's a
131
+ # capacity-governance concern rather than runtime-hardening posture.
132
+ "category": category,
120
133
  "message": message,
121
134
  "path": path,
122
135
  "module": module,
@@ -38,7 +38,7 @@ from .planner import build_plan
38
38
  from .preflight import preflight_passed, run_preflight
39
39
  from .renderer import render_compose
40
40
  from .security import PrecomputedRender, run_security_validation
41
- from .security_common import SEVERITY_ORDER, infer_profile_class
41
+ from .security_common import COMPLIANCE_CATEGORIES, SEVERITY_ORDER, infer_profile_class
42
42
  from .state import format_state_output, group_services_by_health, parse_compose_ps_json
43
43
  from .up_runner import (
44
44
  DEFAULT_TIMEOUT_SECONDS,
@@ -875,6 +875,58 @@ def _unverifiable_image_finding(message: str) -> dict[str, Any]:
875
875
  }
876
876
 
877
877
 
878
+ _UNCATEGORIZED_LABEL = "uncategorized"
879
+
880
+
881
+ def _filter_findings_by_category(
882
+ findings: list[dict[str, Any]], categories: list[str] | None
883
+ ) -> list[dict[str, Any]]:
884
+ """
885
+ Restrict findings to the given compliance control categories.
886
+
887
+ Every finding source (the declarative rule-set engine, scan_k8s_security,
888
+ image verification) tags its findings with a "category" at generation
889
+ time (see cli.security.run_security_validation(), cli.k8s_security,
890
+ cli.image_verification), so this is a uniform key lookup; there is no
891
+ remaining source of genuinely uncategorized findings today, but a
892
+ missing "category" is still treated as excluded rather than raising, in
893
+ case a future finding source doesn't set one.
894
+ """
895
+ if not categories:
896
+ return findings
897
+ wanted = set(categories)
898
+ return [f for f in findings if f.get("category") in wanted]
899
+
900
+
901
+ def _group_findings_by_category(
902
+ findings: list[dict[str, Any]],
903
+ ) -> dict[str, list[dict[str, Any]]]:
904
+ """
905
+ Group findings by their compliance control category, preserving each
906
+ group's existing (severity/rule_id/...) ordering. A finding without a
907
+ "category" is grouped under "uncategorized" rather than dropped, as a
908
+ defensive fallback (see _filter_findings_by_category()); in practice
909
+ every current finding source sets one.
910
+ """
911
+ grouped: dict[str, list[dict[str, Any]]] = {}
912
+ for finding in findings:
913
+ grouped.setdefault(finding.get("category") or _UNCATEGORIZED_LABEL, []).append(finding)
914
+ return dict(sorted(grouped.items()))
915
+
916
+
917
+ def _category_heading(category: str) -> str:
918
+ """
919
+ Render a "== <label> (<category>) ==" heading for --group-by-category,
920
+ falling back to just "== <category> ==" for the uncategorized bucket,
921
+ which has no COMPLIANCE_CATEGORIES entry.
922
+ """
923
+ entry = COMPLIANCE_CATEGORIES.get(category)
924
+ if entry is None:
925
+ return f"== {category} =="
926
+ label, _rationale = entry
927
+ return f"== {label} ({category}) =="
928
+
929
+
878
930
  def _run_image_verification(
879
931
  profile_path: str,
880
932
  environment: str | None,
@@ -1159,6 +1211,21 @@ def main() -> int:
1159
1211
  "By default, values are redacted to avoid echoing real secrets to stdout/CI logs."
1160
1212
  ),
1161
1213
  )
1214
+ test_parser.add_argument(
1215
+ "--category",
1216
+ action="append",
1217
+ choices=COMPLIANCE_CATEGORIES,
1218
+ help=(
1219
+ "Only print security findings tagged with this compliance control "
1220
+ "category (repeatable); does not affect the security stage's "
1221
+ "pass/fail outcome. See `cds security --help` for details."
1222
+ ),
1223
+ )
1224
+ test_parser.add_argument(
1225
+ "--group-by-category",
1226
+ action="store_true",
1227
+ help="Print security findings grouped by compliance control category instead of one flat list.",
1228
+ )
1162
1229
  _add_hardened_arg(test_parser)
1163
1230
  _add_image_source_arg(test_parser)
1164
1231
 
@@ -1283,6 +1350,25 @@ def main() -> int:
1283
1350
  "tests/fixtures/signed-images.json) for offline verification."
1284
1351
  ),
1285
1352
  )
1353
+ security_parser.add_argument(
1354
+ "--category",
1355
+ action="append",
1356
+ choices=COMPLIANCE_CATEGORIES,
1357
+ help=(
1358
+ "Only print findings tagged with this compliance control category "
1359
+ "(repeatable); does not change which rules run or the command's "
1360
+ "exit code -- a high-severity finding outside the requested "
1361
+ "category still fails the scan. Categories are an informational "
1362
+ "readiness aid for organizing findings against a user's own risk "
1363
+ "assessment (e.g. NIS2/Cyberbeveiligingswet), not a compliance "
1364
+ "certification -- see docs/security-compliance-categories.md."
1365
+ ),
1366
+ )
1367
+ security_parser.add_argument(
1368
+ "--group-by-category",
1369
+ action="store_true",
1370
+ help="Print findings grouped by compliance control category instead of one flat list.",
1371
+ )
1286
1372
 
1287
1373
  diff_parser = subparsers.add_parser(
1288
1374
  "diff",
@@ -1941,14 +2027,29 @@ def main() -> int:
1941
2027
  ))
1942
2028
  for diag in sec_diags:
1943
2029
  print(diag.format(), file=sys.stderr)
1944
- for f in findings:
1945
- print(f"[{f['severity'].upper()}] {f['rule_id']} {f['message']}")
2030
+ displayed_findings = _filter_findings_by_category(findings, args.category)
2031
+ if args.group_by_category:
2032
+ for category, group in _group_findings_by_category(displayed_findings).items():
2033
+ print(_category_heading(category))
2034
+ for f in group:
2035
+ print(f"[{f['severity'].upper()}] {f['rule_id']} {f['message']}")
2036
+ else:
2037
+ for f in displayed_findings:
2038
+ print(f"[{f['severity'].upper()}] {f['rule_id']} {f['message']}")
1946
2039
  # A W096 warning means rendered-compose-scoped rules (e.g.
1947
2040
  # CDS-SEC-070) were silently skipped due to an unexpected
1948
2041
  # error during rendering. Treat that the same as a failed
1949
2042
  # security stage rather than letting any non-high finding
1950
2043
  # (or no finding at all) mask the fact that some checks
1951
2044
  # never ran (GHSA-mx5p-cv63-6829).
2045
+ #
2046
+ # security_ok is intentionally computed from the full,
2047
+ # unfiltered `findings`, not `displayed_findings`: --category
2048
+ # is a display-only filter (see docs/security-compliance-
2049
+ # categories.md), so narrowing what's printed must never
2050
+ # narrow what counts as a failing scan -- that would let a
2051
+ # real high-severity finding outside the requested category
2052
+ # silently report success.
1952
2053
  render_scan_skipped = any(d.code == "W096" for d in sec_diags)
1953
2054
  security_ok = (
1954
2055
  not render_scan_skipped
@@ -2293,15 +2394,38 @@ def main() -> int:
2293
2394
  print("No security findings.")
2294
2395
  return 0
2295
2396
 
2296
- for f in findings:
2297
- print(f"[{f['severity'].upper()}] {f['rule_id']} {f['message']}")
2298
- print(f" object: {f['path']}")
2299
- print(f" module: {f['module']}")
2300
- if f["value"] is not None:
2301
- print(f" value: {f['value']}")
2302
- for rec in f["recommendation"]:
2303
- print(f" fix: {rec}")
2304
- print()
2397
+ # --category is a display-only filter (see docs/security-compliance-
2398
+ # categories.md): it must never narrow what counts as a failing scan,
2399
+ # only what gets printed. The exit code below is deliberately
2400
+ # computed from the full, unfiltered `findings`, not
2401
+ # `displayed_findings` -- otherwise a real high-severity finding
2402
+ # outside the requested category would silently report success.
2403
+ displayed_findings = _filter_findings_by_category(findings, args.category)
2404
+
2405
+ if not displayed_findings:
2406
+ print(f"No security findings in category: {', '.join(sorted(set(args.category)))}.")
2407
+ elif args.group_by_category:
2408
+ for category, group in _group_findings_by_category(displayed_findings).items():
2409
+ print(_category_heading(category))
2410
+ for f in group:
2411
+ print(f"[{f['severity'].upper()}] {f['rule_id']} {f['message']}")
2412
+ print(f" object: {f['path']}")
2413
+ print(f" module: {f['module']}")
2414
+ if f["value"] is not None:
2415
+ print(f" value: {f['value']}")
2416
+ for rec in f["recommendation"]:
2417
+ print(f" fix: {rec}")
2418
+ print()
2419
+ else:
2420
+ for f in displayed_findings:
2421
+ print(f"[{f['severity'].upper()}] {f['rule_id']} {f['message']}")
2422
+ print(f" object: {f['path']}")
2423
+ print(f" module: {f['module']}")
2424
+ if f["value"] is not None:
2425
+ print(f" value: {f['value']}")
2426
+ for rec in f["recommendation"]:
2427
+ print(f" fix: {rec}")
2428
+ print()
2305
2429
 
2306
2430
  # As above: a W096 warning means some rendered-compose-scoped rules
2307
2431
  # were skipped, so even when only non-high findings are present the
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "compatibility-registry.schema.json",
3
+ "version": "1.0.0",
4
+ "metadata": {
5
+ "name": "cds-core-compatibility-registry",
6
+ "description": "Central record of module pairings known to have been tested together (\"tested\"), or known NOT to work together despite matching a contract's kind (\"unsupported\"). Kept as a single file rather than scattered across individual module.yaml documents so composition knowledge about the whole stack stays in one place (see issue #350). A pairing with no entry here is only structurally compatible (same contract kind); it is neither vouched for nor ruled out."
7
+ },
8
+ "pairings": [
9
+ {
10
+ "contract": "sql-database",
11
+ "provider": "warehouse/postgres",
12
+ "consumer": "orchestration/dagster",
13
+ "status": "tested",
14
+ "notes": "Exercised end-to-end by profiles/local-dagster-postgres-superset/profile.yaml, covered by the \"CDS Test (Stable Profile)\" CI workflow."
15
+ },
16
+ {
17
+ "contract": "sql-database",
18
+ "provider": "warehouse/postgres",
19
+ "consumer": "bi/superset",
20
+ "status": "tested",
21
+ "notes": "Exercised end-to-end by profiles/local-dagster-postgres-superset/profile.yaml, covered by the \"CDS Test (Stable Profile)\" CI workflow."
22
+ },
23
+ {
24
+ "contract": "cache-service",
25
+ "provider": "cache/keydb",
26
+ "consumer": "bi/superset",
27
+ "status": "tested",
28
+ "notes": "Exercised end-to-end by profiles/local-dagster-postgres-superset/profile.yaml, covered by the \"CDS Test (Stable Profile)\" CI workflow."
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "./compatibility-registry.schema.json",
4
+ "title": "Composable Data Stack Compatibility Registry",
5
+ "description": "Central, non-scattered record of module pairings for a given contract that are known to have been tested together, or explicitly known NOT to work together. This is deliberately separate from plain contract `kind` matching (structural compatibility): an entry here expresses practical, known-good/known-bad compatibility, not just shape compatibility. Absence of an entry for a pairing means 'not yet recorded' -- it is neither tested nor unsupported.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["version", "metadata", "pairings"],
9
+ "properties": {
10
+ "$schema": {
11
+ "type": "string"
12
+ },
13
+ "version": {
14
+ "type": "string"
15
+ },
16
+ "metadata": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "required": ["name"],
20
+ "properties": {
21
+ "name": {
22
+ "type": "string",
23
+ "minLength": 1
24
+ },
25
+ "description": {
26
+ "type": "string"
27
+ }
28
+ }
29
+ },
30
+ "pairings": {
31
+ "type": "array",
32
+ "items": {
33
+ "$ref": "#/$defs/pairing"
34
+ }
35
+ }
36
+ },
37
+ "$defs": {
38
+ "pairing": {
39
+ "type": "object",
40
+ "additionalProperties": false,
41
+ "required": ["contract", "provider", "consumer", "status"],
42
+ "properties": {
43
+ "contract": {
44
+ "type": "string",
45
+ "pattern": "^[a-z0-9-]+$",
46
+ "description": "Contract kind this pairing applies to (matches a module's provides[].contract.kind / consumes[].contract.kind), not a specific provide-name. A provider can expose several provide-names under the same kind (e.g. warehouse/postgres provides \"sql-database\", \"dagster-database\", and \"superset-database\", all kind \"sql-database\"); one row here is intended to cover the provider/consumer pair for all of them at this kind granularity. A row also carries no module version, so a \"tested\" status remains valid across version changes on either side (tracked as a follow-up, see issue #750)."
47
+ },
48
+ "provider": {
49
+ "type": "string",
50
+ "pattern": "^[a-z0-9-]+/[a-z0-9-]+$",
51
+ "description": "Providing module's identity, as \"<metadata.category>/<metadata.name>\" (e.g. \"warehouse/postgres\"). Stable across profiles, unlike a profile-scoped module instance id or a source path."
52
+ },
53
+ "consumer": {
54
+ "type": "string",
55
+ "pattern": "^[a-z0-9-]+/[a-z0-9-]+$",
56
+ "description": "Consuming module's identity, as \"<metadata.category>/<metadata.name>\" (e.g. \"orchestration/dagster\")."
57
+ },
58
+ "status": {
59
+ "type": "string",
60
+ "enum": ["tested", "unsupported"],
61
+ "description": "\"tested\": this exact provider/consumer pairing for this contract has been exercised together (e.g. by a profile under active CI coverage) and is known-good. \"unsupported\": this pairing is structurally compatible (same contract kind) but has been found NOT to work together in practice; validation reports this as an error."
62
+ },
63
+ "notes": {
64
+ "type": "string",
65
+ "description": "Free-text context, e.g. which profile/test exercises a \"tested\" pairing, or why an \"unsupported\" pairing fails."
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
@@ -154,6 +154,21 @@
154
154
  }
155
155
  }
156
156
  }
157
+ },
158
+ "security": {
159
+ "type": "object",
160
+ "additionalProperties": false,
161
+ "properties": {
162
+ "waivers": {
163
+ "type": "object",
164
+ "additionalProperties": false,
165
+ "properties": {
166
+ "plaintextEndpointExposure": {
167
+ "$ref": "#/$defs/plaintextEndpointExposureWaiver"
168
+ }
169
+ }
170
+ }
171
+ }
157
172
  }
158
173
  }
159
174
  }
@@ -231,6 +246,19 @@
231
246
  "default": "bridge"
232
247
  }
233
248
  }
249
+ },
250
+ "plaintextEndpointExposureWaiver": {
251
+ "type": "object",
252
+ "additionalProperties": false,
253
+ "required": ["reason"],
254
+ "properties": {
255
+ "reason": {
256
+ "type": "string",
257
+ "minLength": 1,
258
+ "pattern": "\\S",
259
+ "description": "Must contain at least one non-whitespace character; cli/security.py strips and treats an all-whitespace reason as unset (no waiver applied, so CDS-SEC-074 still fires), so a schema that only enforced minLength: 1 would silently accept a value the code then discards."
260
+ }
261
+ }
234
262
  }
235
263
  }
236
264
  }
@@ -49,6 +49,20 @@
49
49
  "observability-leakage"
50
50
  ]
51
51
  },
52
+ "complianceCategory": {
53
+ "type": "string",
54
+ "description": "Informational compliance control category this rule's finding maps onto (e.g. for a user's own NIS2/Cyberbeveiligingswet risk assessment). This is a readiness aid, not a legal or regulatory certification of the rule or category -- see docs/security-compliance-categories.md.",
55
+ "enum": [
56
+ "access-control",
57
+ "secrets-management",
58
+ "network-exposure",
59
+ "encryption-in-transit",
60
+ "configuration-management",
61
+ "system-hardening",
62
+ "logging-monitoring",
63
+ "patching"
64
+ ]
65
+ },
52
66
  "scopeItem": {
53
67
  "type": "string",
54
68
  "enum": [
@@ -183,6 +197,7 @@
183
197
  "title",
184
198
  "severity",
185
199
  "category",
200
+ "complianceCategory",
186
201
  "scope",
187
202
  "match",
188
203
  "message",
@@ -207,6 +222,9 @@
207
222
  "category": {
208
223
  "$ref": "#/$defs/category"
209
224
  },
225
+ "complianceCategory": {
226
+ "$ref": "#/$defs/complianceCategory"
227
+ },
210
228
  "scope": {
211
229
  "type": "array",
212
230
  "minItems": 1,
@@ -231,6 +249,11 @@
231
249
  "enabled": {
232
250
  "type": "boolean",
233
251
  "default": true
252
+ },
253
+ "codeEnforced": {
254
+ "type": "boolean",
255
+ "default": true,
256
+ "description": "For a scope: [\"none\"] rule that is actually enforced entirely in code (not by the declarative match engine -- see the rule's $comment), this is the real on/off switch a custom rule set can flip to disable that code path. `enabled` stays false on such rules per the scope-none convention (it can never produce a declarative-engine finding), so `enabled` alone cannot represent whether the code-level check is active."
234
257
  }
235
258
  }
236
259
  }
@@ -249,6 +272,7 @@
249
272
  "title": "Hardcoded secret-like value",
250
273
  "severity": "high",
251
274
  "category": "secrets",
275
+ "complianceCategory": "secrets-management",
252
276
  "scope": ["profile-raw", "module-values", "service-env"],
253
277
  "match": {
254
278
  "all": [