From 5df924285767009af44642199cffb1a0a0890103 Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Fri, 28 Aug 2026 10:27:46 +0200 Subject: [PATCH 1/5] docs(prowler): define CHK.014 CIS behaviour (#422) --- .../__init__.py | 1 + .../chk014_compliance_cis_benchmark.feature | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 prowler/tests/behaviour/chk014_compliance_cis_benchmark/__init__.py create mode 100644 prowler/tests/behaviour/chk014_compliance_cis_benchmark/chk014_compliance_cis_benchmark.feature diff --git a/prowler/tests/behaviour/chk014_compliance_cis_benchmark/__init__.py b/prowler/tests/behaviour/chk014_compliance_cis_benchmark/__init__.py new file mode 100644 index 00000000..4f6726a1 --- /dev/null +++ b/prowler/tests/behaviour/chk014_compliance_cis_benchmark/__init__.py @@ -0,0 +1 @@ +"""CHK.014 CIS benchmark executable behavior.""" diff --git a/prowler/tests/behaviour/chk014_compliance_cis_benchmark/chk014_compliance_cis_benchmark.feature b/prowler/tests/behaviour/chk014_compliance_cis_benchmark/chk014_compliance_cis_benchmark.feature new file mode 100644 index 00000000..1076cd17 --- /dev/null +++ b/prowler/tests/behaviour/chk014_compliance_cis_benchmark/chk014_compliance_cis_benchmark.feature @@ -0,0 +1,65 @@ +Feature: CHK.014 CIS benchmark assessments + OpenAEV operators can run the supported Prowler 5.36 CIS benchmark for each + provider without exposing framework routing as form input or changing existing + check and service selection channels. + + Scenario Outline: A canonical CIS route selects one exact Prowler compliance framework + Given the executable Prowler registry contains the canonical route "" + When a valid provider assessment is executed through "" + Then the Prowler client is called exactly once with compliance "" + And no check filter or service selector is supplied + + Examples: + | route | compliance | + | cis/aws | cis_3.0_aws | + | cis/azure | cis_3.0_azure | + | cis/gcp | cis_3.0_gcp | + | cis/kubernetes | cis_1.12_kubernetes | + + Scenario: The fifteen executable contracts have stable canonical identities + Given the executable Prowler registry + Then it contains the existing eleven routes followed by the four CIS routes + And every CIS route has compliance-identifying labels and no selector field + + Scenario: Unsupported compliance combinations stop before the client + Given a CIS contract is paired with another provider's compliance selector + When the invalid contract execution is attempted + Then the request is rejected before the Prowler client is called + + Scenario: CIS findings preserve the CHK.005 model and compliance values + Given ordered duplicate provider and non-provider OCSF records for a CIS route + When the CIS assessment completes + Then provider findings retain source order, duplicates, and compliance tags + And the same findings feed shared Text and Vulnerability outputs and dynamic Rich trace + + Scenario Outline: Runtime dispatch emits exact compliance argv without real execution + Given a fake CLI engine and valid provider runtime message for "" + When the injector processes the runtime message + Then exactly one fake CLI request contains "--compliance" followed by "" + And provider credentials and temporary leases follow their existing lifecycle + And credentials, stderr, and temporary-path canaries are absent from the callback + + Examples: + | route | compliance | + | cis/aws | cis_3.0_aws | + | cis/azure | cis_3.0_azure | + | cis/gcp | cis_3.0_gcp | + | cis/kubernetes | cis_1.12_kubernetes | + +# ---- Constraints identified ---- + Scenario: Existing check and service channels remain dedicated + Given fake Prowler requests using an existing check filter or service selector + When those requests are rendered without compliance selection + Then check filters still use "-c" and services still use "--services" + And neither request emits "--compliance" + + Scenario: A compliance selector cannot target another provider + Given a fake AWS Prowler client request with Azure CIS compliance + When the client request is validated + Then it is rejected before credential materialization or fake CLI execution + + Scenario: CIS routing constants are internal + Given a serialized CIS OpenAEV contract + When its fields are inspected + Then only the existing provider fields are public + And no compliance or framework selector field is present From fa788f78ae6ba2bc2dfc63cdacde324b0bb9aa1d Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Fri, 28 Aug 2026 10:32:01 +0200 Subject: [PATCH 2/5] test(prowler): prove CHK.014 CIS RED (#422) --- .../conftest.py | 136 ++++ ...est_chk014_compliance_cis_benchmark_bdd.py | 724 ++++++++++++++++++ .../test_outputs_registry_runtime.py | 8 +- 3 files changed, 866 insertions(+), 2 deletions(-) create mode 100644 prowler/tests/behaviour/chk014_compliance_cis_benchmark/conftest.py create mode 100644 prowler/tests/behaviour/chk014_compliance_cis_benchmark/test_chk014_compliance_cis_benchmark_bdd.py diff --git a/prowler/tests/behaviour/chk014_compliance_cis_benchmark/conftest.py b/prowler/tests/behaviour/chk014_compliance_cis_benchmark/conftest.py new file mode 100644 index 00000000..b01e223c --- /dev/null +++ b/prowler/tests/behaviour/chk014_compliance_cis_benchmark/conftest.py @@ -0,0 +1,136 @@ +"""Local deterministic CHK.014 fixtures.""" + +from dataclasses import dataclass, field +from typing import Any + +import pytest + + +@dataclass(frozen=True) +class RecordedLog: + """One AppLogger-compatible call captured without formatting side effects.""" + + level: str + message: str + metadata: dict[str, object] | None = None + exc_info: bool | None = None + + +class _RecordingLocalLogger: + """Capture the direct standard-library ERROR path used by the injector.""" + + def __init__(self, events: list[RecordedLog]) -> None: + self._events = events + + def error( + self, + message: str, + *, + exc_info: bool, + extra: dict[str, object], + ) -> None: + """Record safe ERROR metadata in the same shape accepted by AppLogger.""" + attributes = extra.get("attributes") + metadata = dict(attributes) if isinstance(attributes, dict) else None + self._events.append(RecordedLog("error", message, metadata, exc_info)) + + +@dataclass +class RecordingLogger: + """Record the lifecycle logger surface exposed by the OpenAEV helper.""" + + events: list[RecordedLog] = field(default_factory=list) + + def __post_init__(self) -> None: + """Attach the direct ERROR surface to the shared event stream.""" + self.local_logger = _RecordingLocalLogger(self.events) + + def debug(self, message: str, metadata: dict[str, object]) -> None: + """Record one DEBUG lifecycle event.""" + self.events.append(RecordedLog("debug", message, metadata)) + + def info(self, message: str, metadata: dict[str, object] | None = None) -> None: + """Record one INFO lifecycle event.""" + self.events.append(RecordedLog("info", message, metadata)) + + def warning(self, message: str) -> None: + """Record one WARNING lifecycle event.""" + self.events.append(RecordedLog("warning", message)) + + +@pytest.fixture +def provider_forms() -> dict[str, dict[str, object]]: + """Return valid placeholder-only forms for all four providers.""" + return { + "aws": { + "aws_access_key_id": "CANARY-AWS-ACCESS", + "aws_secret_access_key": "CANARY-AWS-SECRET", + "aws_account_id": "123456789012", + "aws_region": "eu-west-1", + "aws_session_token": "CANARY-AWS-SESSION", + }, + "azure": { + "azure_tenant_id": "CANARY-AZURE-TENANT", + "azure_client_id": "CANARY-AZURE-CLIENT", + "azure_client_secret": "CANARY-AZURE-SECRET", + "azure_subscription_id": "subscription-123", + "azure_provider": "Microsoft.Compute", + }, + "gcp": { + "gcp_service_account_json": "CANARY-GCP-SERVICE-ACCOUNT", + "gcp_project_id": "acme-prod", + }, + "kubernetes": { + "kubernetes_kubeconfig": "CANARY-KUBECONFIG-CONTENT", + "kubernetes_context": "acme-prod-cluster", + }, + } + + +@pytest.fixture +def cis_ocsf_record_factory() -> Any: + """Build a complete OCSF record with caller-controlled compliance values.""" + + def build( + title: str, + *, + provider: str = "aws", + status: str = "FAIL", + compliance: object = ("1.1", "1.1", "2.2"), + ) -> dict[str, Any]: + if status == "PASS": + record_status, status_code = "New", "PASS" + elif status == "MUTED": + record_status, status_code = "Suppressed", "FAIL" + else: + record_status, status_code = "New", status + resources = [{"uid": f"asset-{title}", "name": f"Asset {title}"}] + unmapped = {"compliance": compliance} + record = { + "finding_info": { + "uid": f"check-{title}", + "title": title, + "desc": f"Description {title}", + }, + "status": record_status, + "status_code": status_code, + "severity": "High", + "resources": resources, + "unmapped": unmapped, + "remediation": { + "desc": f"Remediate {title}", + "references": ["https://example.invalid/remediation"], + }, + } + if provider.casefold() == "kubernetes": + resources[0]["namespace"] = "default" + unmapped.update(provider=provider, provider_uid="acme-prod-cluster") + else: + record["cloud"] = { + "provider": provider, + "region": "eu-west-1", + "account": {"uid": "account-123"}, + } + return record + + return build diff --git a/prowler/tests/behaviour/chk014_compliance_cis_benchmark/test_chk014_compliance_cis_benchmark_bdd.py b/prowler/tests/behaviour/chk014_compliance_cis_benchmark/test_chk014_compliance_cis_benchmark_bdd.py new file mode 100644 index 00000000..050c0c68 --- /dev/null +++ b/prowler/tests/behaviour/chk014_compliance_cis_benchmark/test_chk014_compliance_cis_benchmark_bdd.py @@ -0,0 +1,724 @@ +"""Raw pytest executable contract for CHK.014.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, cast, get_args + +import pytest +from pydantic import SecretStr +from pyoaev.configuration import ConfigLoaderOAEV + +from prowler._core.cli_engine import ( + CommandResult, + ExecutionSpecification, + OutputSpecification, + ValidatedCommandRequest, +) +from prowler._core.prowler_client import ( + OUTPUT_ARTIFACT_FILENAME, + ProwlerClientFactory, +) +from prowler.contracts import DEFAULT_PROWLER_CONTRACTS, stable_contract_id +from prowler.models.configs.config_loader import ( + ConfigLoader, + InjectorConfig, + ProwlerConfig, +) +from prowler.models.provider_inputs import AwsProviderInput + +from .conftest import RecordingLogger + +_ROUTES = ( + ("cis/aws", "aws", "cis_3.0_aws"), + ("cis/azure", "azure", "cis_3.0_azure"), + ("cis/gcp", "gcp", "cis_3.0_gcp"), + ("cis/kubernetes", "kubernetes", "cis_1.12_kubernetes"), +) +_TEMP_PATHS = { + "gcp": Path("/tmp/CANARY-GCP-CREDENTIAL.json"), # noqa: S108 + "kubernetes": Path("/tmp/CANARY-KUBE-CREDENTIAL.yaml"), # noqa: S108 +} +_ASSESSMENT_RECEIVED = "[PROWLER_INJECTOR] - Assessment received" +_RECEPTION_ACKNOWLEDGED = "[PROWLER_INJECTOR] - Reception acknowledged" +_CONTRACT_RESOLVED = "[PROWLER_INJECTOR] - Contract resolved" +_ASSESSMENT_VALIDATED = "[PROWLER_INJECTOR] - Assessment input validated" +_EXECUTION_STARTED = "[PROWLER_INJECTOR] - Assessment execution starting" +_ASSESSMENT_SUCCEEDED = "[PROWLER_INJECTOR] - Assessment completed" +_ASSESSMENT_FAILED = "[PROWLER_INJECTOR] - Assessment failed" +_CALLBACK_COMPLETED = "[PROWLER_INJECTOR] - Assessment callback completed" + + +def _specification(arguments: tuple[str, ...] = ()) -> ExecutionSpecification: + return ExecutionSpecification( + executable="/fake/prowler", + arguments=arguments, + environment=(), + working_directory=None, + input_bytes=b"", + output=OutputSpecification(parser="raw"), + timeout_seconds=1.0, + maximum_accepted_output_bytes=1024, + ) + + +@dataclass +class _ClientFactory: + result: CommandResult + calls: list[tuple[Any, Any, tuple[str, ...], object, object]] = field( + default_factory=list + ) + + def run( + self, + config: Any, + provider: Any, + *, + check_filters: Any = (), + service_selector: object = None, + compliance_selector: object = None, + ) -> CommandResult: + self.calls.append( + ( + config, + provider, + tuple(check_filters), + service_selector, + compliance_selector, + ) + ) + return self.result + + +def _contract(route: str) -> Any: + return DEFAULT_PROWLER_CONTRACTS.resolve(str(stable_contract_id(route))) + + +@pytest.mark.parametrize(("route", "provider_name", "compliance"), _ROUTES) +def test_route_selects_exact_typed_compliance_once( + route: str, + provider_name: str, + compliance: str, + provider_forms: dict[str, dict[str, object]], +) -> None: + """A route-owned selector crosses the client seam once without other selectors.""" + factory = _ClientFactory(CommandResult(specification=_specification())) + contract = _contract(route) + contract._client_factory = factory + + contract.execute( + ProwlerConfig(), contract.parse_input(provider_forms[provider_name]) + ) + + assert len(factory.calls) == 1 + assert factory.calls[0][2:] == ((), None, compliance) + + +def test_compliance_selector_type_contains_only_supported_cis_values() -> None: + """The internal typed seam admits only the four evidenced Prowler values.""" + import prowler._core.prowler_client as client_api + + selector_type = client_api.__dict__.get("ComplianceSelector") + + assert selector_type is not None + assert get_args(selector_type) == tuple(item[2] for item in _ROUTES) + + +def test_registry_has_exact_fifteen_canonical_contracts_without_selector_fields() -> ( + None +): + """The executable public surface is stable, ordered, and not user-selectable.""" + serialized = DEFAULT_PROWLER_CONTRACTS.contracts() + routes = ( + "aws", + "azure", + "gcp", + "kubernetes", + "aws/iam", + "aws/s3", + "aws/ec2", + "azure/iam", + "azure/storage", + "gcp/iam", + "gcp/compute", + *(item[0] for item in _ROUTES), + ) + + assert [item["contract_id"] for item in serialized] == [ + str(stable_contract_id(route)) for route in routes + ] + for item, (route, provider_name, _) in zip(serialized[11:], _ROUTES, strict=True): + content = json.loads(item["contract_content"]) + assert "cis" in content["label"]["en"].casefold() + keys = tuple(field["key"] for field in content["fields"]) + assert keys + assert all("compliance" not in key and "framework" not in key for key in keys) + assert all(route in output["labels"] for output in content["outputs"]) + assert keys == tuple( + field.key for field in _contract(provider_name).build_provider_fields() + ) + + +def test_unsupported_contract_selector_is_rejected_pre_client( + provider_forms: dict[str, dict[str, object]], +) -> None: + """Invalid internal route metadata cannot consume the CHK.004 seam.""" + import prowler.contracts as contract_api + + cis_contract = contract_api.__dict__.get("CisComplianceContract") + assert cis_contract is not None + + class InvalidCisContract(cis_contract): + contract_id = str(stable_contract_id("cis/aws")) + external_id = "prowler:cis/aws" + route_name = "cis/aws" + provider = "aws" + label = "Invalid" + compliance_selector = "cis_3.0_azure" + + factory = _ClientFactory(CommandResult(specification=_specification())) + contract = InvalidCisContract(factory) + + with pytest.raises(ValueError, match="unsupported CIS compliance selection"): + contract.execute(ProwlerConfig(), contract.parse_input(provider_forms["aws"])) + + assert factory.calls == [] + + +def test_mapping_preserves_model_duplicates_compliance_outputs_and_trace( + provider_forms: dict[str, dict[str, object]], cis_ocsf_record_factory: Any +) -> None: + """Keep CHK.005 findings unchanged across all existing presentation channels.""" + duplicate = cis_ocsf_record_factory( + "duplicate", compliance={"CIS-3.0": ["1.1", "1.1", "2.2"]} + ) + records = [ + cis_ocsf_record_factory("first", status="PASS", compliance=["a", "b"]), + duplicate, + duplicate.copy(), + cis_ocsf_record_factory("excluded", provider="azure"), + cis_ocsf_record_factory("last", status="PASS", compliance=["z"]), + ] + artifact = json.dumps(records).encode() + factory = _ClientFactory( + CommandResult( + specification=_specification(), + return_code=0, + stdout=b"\x1b[32mconsole output is not OCSF JSON\x1b[0m", + parsed=artifact, + ) + ) + contract = _contract("cis/aws") + contract._client_factory = factory + provider = contract.parse_input(provider_forms["aws"]) + + outcome = contract.execute(ProwlerConfig(), provider) + payload = contract.output_payload(outcome.findings) + trace = contract.render_trace( + provider, + outcome.findings, + 1, + raw_record_count=outcome.raw_record_count, + raw_output_bytes=outcome.raw_output_bytes, + raw_preview=outcome.raw_preview, + ) + + expected = ("first", "duplicate", "duplicate", "last") + assert tuple(item.value for item in outcome.findings) == expected + assert tuple(item.compliance_tags for item in outcome.findings) == ( + ("a", "b"), + ("CIS-3.0:1.1", "CIS-3.0:1.1", "CIS-3.0:2.2"), + ("CIS-3.0:1.1", "CIS-3.0:1.1", "CIS-3.0:2.2"), + ("z",), + ) + assert tuple(json.loads(item)["value"] for item in payload["findings"]) == expected + assert tuple(item["name"] for item in payload["vulnerabilities"]) == ( + "duplicate", + "duplicate", + ) + raw_section_index = trace.index("[PROWLER] Raw OCSF evidence (bounded preview)") + rendered_rows = tuple( + line + for line in trace[:raw_section_index].splitlines() + if any(name in line for name in ("first", "duplicate", "last")) + ) + assert ( + tuple(next(name for name in expected if name in line) for line in rendered_rows) + == expected + ) + assert "excluded" not in trace[:raw_section_index] + assert trace.index("Prowler Findings") < raw_section_index + assert outcome.raw_record_count == 5 + assert outcome.raw_output_bytes == len(artifact) + assert "cis/aws" in trace + assert "compliance=cis_3.0_aws" in trace + + +@dataclass +class _Engine: + payload: bytes + lifecycle: list[str] + requests: list[ValidatedCommandRequest] = field(default_factory=list) + + def run(self, request: ValidatedCommandRequest) -> CommandResult: + self.lifecycle.append("engine") + self.requests.append(request) + arguments = tuple(request.arguments) + output_directory = Path(arguments[arguments.index("--output-directory") + 1]) + (output_directory / OUTPUT_ARTIFACT_FILENAME).write_bytes(self.payload) + specification = ExecutionSpecification.from_request(request) + return CommandResult( + specification=ExecutionSpecification( + executable=specification.executable, + arguments=(*specification.arguments, "PROCESS-ARGV-CANARY"), + environment=( + *specification.environment, + ("PROCESS-ENV-NAME-CANARY", "PROCESS-ENV-VALUE-CANARY"), + ), + working_directory="/tmp/PROCESS-WORKDIR-CANARY", # noqa: S108 + input_bytes=specification.input_bytes, + output=specification.output, + timeout_seconds=specification.timeout_seconds, + maximum_accepted_output_bytes=( + specification.maximum_accepted_output_bytes + ), + ), + return_code=0, + stdout=b"\x1b[31mPROCESS-STDOUT-CANARY\x1b[0m", + stderr=b"PROCESS-STDERR-CANARY", + ) + + +@dataclass +class _EngineFactory: + engine: _Engine + calls: int = 0 + + def create(self) -> _Engine: + self.calls += 1 + return self.engine + + +@dataclass +class _CredentialLease: + lifecycle: list[str] + path: Path + + def cleanup(self) -> None: + self.lifecycle.append("cleanup") + + +@dataclass +class _CredentialLeaseFactory: + lifecycle: list[str] + calls: list[tuple[SecretStr, str]] = field(default_factory=list) + + def create(self, secret: SecretStr, *, suffix: str) -> _CredentialLease: + self.calls.append((secret, suffix)) + self.lifecycle.append(f"create:{suffix}") + provider_name = "gcp" if suffix == ".json" else "kubernetes" + return _CredentialLease(self.lifecycle, _TEMP_PATHS[provider_name]) + + +class _InjectApi: + def __init__(self) -> None: + self.events: list[tuple[str, str, dict[str, Any]]] = [] + + def execution_reception(self, *, inject_id: str, data: dict[str, Any]) -> None: + self.events.append(("reception", inject_id, data)) + + def execution_callback(self, *, inject_id: str, data: dict[str, Any]) -> None: + self.events.append(("callback", inject_id, data)) + + +class _Helper: + def __init__(self) -> None: + self.api = type("Api", (), {})() + self.api.inject = _InjectApi() + self.injector_logger = RecordingLogger() + + +def _config() -> ConfigLoader: + return ConfigLoader.model_construct( + openaev=ConfigLoaderOAEV( + url="http://127.0.0.1:8080", token="runtime-placeholder" + ), + injector=InjectorConfig(id="injector-test"), + prowler=ProwlerConfig(executable_path="/fake/prowler"), + ) + + +def _message( + route: str, provider_name: str, content: dict[str, object] +) -> dict[str, object]: + return { + "injection": { + "inject_id": f"INJECT-ID-CANARY-{provider_name}", + "injector_contract_id": str(stable_contract_id(route)), + "inject_content": content, + } + } + + +@pytest.mark.parametrize(("route", "provider_name", "compliance"), _ROUTES) +def test_runtime_one_call_exact_compliance_argv_lifecycle_and_canaries( + route: str, + provider_name: str, + compliance: str, + provider_forms: dict[str, dict[str, object]], + cis_ocsf_record_factory: Any, +) -> None: + """Runtime uses one fake request and preserves provider resource handling.""" + from prowler.injector import ProwlerInjector + + callback_name = f"CALLBACK-CANARY-{provider_name}" + finding_name = f"FINDING-CANARY-{provider_name}" + records = [ + cis_ocsf_record_factory(callback_name, provider=provider_name, status="PASS"), + cis_ocsf_record_factory(finding_name, provider=provider_name), + cis_ocsf_record_factory("excluded", provider="unsupported"), + ] + lifecycle: list[str] = [] + engine = _Engine(json.dumps(records).encode(), lifecycle) + engine_factory = _EngineFactory(engine) + leases = _CredentialLeaseFactory(lifecycle) + contract = _contract(route) + contract._client_factory = ProwlerClientFactory(engine_factory, leases) + helper = _Helper() + + ProwlerInjector(_config(), helper).process_message( + _message(route, provider_name, provider_forms[provider_name]) + ) + + assert engine_factory.calls == 1 + assert len(engine.requests) == 1 + arguments = tuple(engine.requests[0].arguments) + compliance_index = arguments.index("--compliance") + assert arguments[compliance_index : compliance_index + 2] == ( + "--compliance", + compliance, + ) + assert "-c" not in arguments + assert "--services" not in arguments + if provider_name in _TEMP_PATHS: + suffix = ".json" if provider_name == "gcp" else ".yaml" + assert lifecycle == [f"create:{suffix}", "engine", "cleanup"] + assert len(leases.calls) == 1 + else: + assert lifecycle == ["engine"] + assert leases.calls == [] + assert tuple(event[0] for event in helper.api.inject.events) == ( + "reception", + "callback", + ) + callback = helper.api.inject.events[1][2] + assert callback["execution_status"] == "SUCCESS" + structured = json.loads(callback["execution_output_structured"]) + assert tuple(json.loads(item)["value"] for item in structured["findings"]) == ( + callback_name, + finding_name, + ) + assert tuple(item["name"] for item in structured["vulnerabilities"]) == ( + finding_name, + ) + assert callback_name in callback["execution_message"] + assert finding_name in callback["execution_message"] + raw_section_index = callback["execution_message"].index( + "[PROWLER] Raw OCSF evidence (bounded preview)" + ) + assert "excluded" not in callback["execution_message"][:raw_section_index] + trace_identifiers = { + "aws": ("123456789012", "eu-west-1"), + "azure": ("subscription-123", "Microsoft.Compute"), + "gcp": ("acme-prod",), + "kubernetes": ("acme-prod-cluster",), + } + assert all( + marker in callback["execution_message"] + for marker in trace_identifiers[provider_name] + ) + assert route in callback["execution_message"] + assert f"compliance={compliance}" in callback["execution_message"] + credential_fields = { + "aws": ( + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + ), + "azure": ( + "azure_tenant_id", + "azure_client_id", + "azure_client_secret", + ), + "gcp": ("gcp_service_account_json",), + "kubernetes": ("kubernetes_kubeconfig",), + } + callback_excluded_canaries = tuple( + str(provider_forms[provider_name][field_name]) + for field_name in credential_fields[provider_name] + ) + ( + "PROCESS-ARGV-CANARY", + "PROCESS-ENV-NAME-CANARY", + "PROCESS-ENV-VALUE-CANARY", + "PROCESS-STDOUT-CANARY", + "PROCESS-STDERR-CANARY", + "/tmp/PROCESS-WORKDIR-CANARY", # noqa: S108 - deliberate leak canary + *(str(path) for path in _TEMP_PATHS.values()), + "EXCEPTION-FIELD-CANARY", + "EXCEPTION-VALUE-CANARY", + ) + assert all( + marker not in json.dumps(callback) for marker in callback_excluded_canaries + ) + + logs = helper.injector_logger.events + assert tuple((event.level, event.message) for event in logs) == ( + ("info", _ASSESSMENT_RECEIVED), + ("debug", _RECEPTION_ACKNOWLEDGED), + ("debug", _CONTRACT_RESOLVED), + ("debug", _ASSESSMENT_VALIDATED), + ("info", _EXECUTION_STARTED), + ("info", _ASSESSMENT_SUCCEEDED), + ("debug", _CALLBACK_COMPLETED), + ) + assert [event.metadata["stage"] for event in logs if event.metadata] == [ + "message_reception", + "reception_acknowledged", + "contract_resolution", + "input_validation", + "assessment_execution", + "assessment_completion", + "callback", + ] + assert all(event.metadata is not None for event in logs) + inject_id = f"INJECT-ID-CANARY-{provider_name}" + assert all( + event.metadata["inject_id"] == inject_id for event in logs if event.metadata + ) + assert all( + 0 <= event.metadata["elapsed_ms"] <= 86_400_000 + for event in logs + if event.metadata + ) + identifier = str(stable_contract_id(route)) + for event in logs[2:]: + assert event.metadata is not None + assert event.metadata["contract_id"] == identifier + assert event.metadata["route"] == route + assert event.metadata["provider"] == provider_name + safe_context = { + "aws": { + "aws_account_id": "123456789012", + "aws_region": "eu-west-1", + "aws_session_token_present": True, + "aws_endpoint_override_present": False, + }, + "azure": { + "azure_tenant_id_present": True, + "azure_client_id_present": True, + "azure_client_secret_present": True, + "azure_subscription_id": "subscription-123", + "azure_provider": "Microsoft.Compute", + }, + "gcp": { + "gcp_project_id": "acme-prod", + "gcp_credentials_present": True, + }, + "kubernetes": { + "kubernetes_context": "acme-prod-cluster", + "kubernetes_credentials_present": True, + }, + } + for event in logs[3:]: + assert event.metadata is not None + assert all( + event.metadata[key] == value + for key, value in safe_context[provider_name].items() + ) + success_metadata = logs[5].metadata + assert success_metadata is not None + assert success_metadata["status"] == "SUCCESS" + assert success_metadata["finding_count"] == 2 + assert success_metadata["vulnerability_count"] == 1 + assert success_metadata["raw_record_count"] == 3 + assert success_metadata["raw_output_bytes"] == len(engine.payload) + callback_metadata = logs[6].metadata + assert callback_metadata is not None + assert callback_metadata["assessment_status"] == "SUCCESS" + assert callback_metadata["delivery_status"] == "SUCCESS" + assert "status" not in callback_metadata + assert "attempted_status" not in callback_metadata + log_excluded_canaries = ( + *callback_excluded_canaries, + callback_name, + finding_name, + ) + assert all(marker not in repr(logs) for marker in log_excluded_canaries) + + invalid_fields = { + "aws": "aws_account_id", + "azure": "azure_subscription_id", + "gcp": "gcp_project_id", + "kubernetes": "kubernetes_context", + } + invalid_field = invalid_fields[provider_name] + invalid_helper = _Helper() + ProwlerInjector(_config(), invalid_helper).process_message( + _message( + route, + provider_name, + { + **provider_forms[provider_name], + invalid_field: " ", + "EXCEPTION-FIELD-CANARY": "EXCEPTION-VALUE-CANARY", + }, + ) + ) + + assert tuple(event[0] for event in invalid_helper.api.inject.events) == ( + "reception", + "callback", + ) + invalid_callback = invalid_helper.api.inject.events[1][2] + assert invalid_callback["execution_status"] == "ERROR" + assert all( + marker not in json.dumps(invalid_callback) for marker in log_excluded_canaries + ) + assert engine_factory.calls == 1 + expected_lifecycle = ( + ["engine"] + if provider_name not in _TEMP_PATHS + else [ + "create:.json" if provider_name == "gcp" else "create:.yaml", + "engine", + "cleanup", + ] + ) + assert lifecycle == expected_lifecycle + + invalid_logs = invalid_helper.injector_logger.events + assert tuple((event.level, event.message) for event in invalid_logs) == ( + ("info", _ASSESSMENT_RECEIVED), + ("debug", _RECEPTION_ACKNOWLEDGED), + ("debug", _CONTRACT_RESOLVED), + ("error", _ASSESSMENT_FAILED), + ("debug", _CALLBACK_COMPLETED), + ) + assert [event.metadata["stage"] for event in invalid_logs if event.metadata] == [ + "message_reception", + "reception_acknowledged", + "contract_resolution", + "input_validation", + "callback", + ] + assert all(event.metadata is not None for event in invalid_logs) + assert all( + event.metadata["inject_id"] == inject_id + for event in invalid_logs + if event.metadata + ) + assert all( + 0 <= event.metadata["elapsed_ms"] <= 86_400_000 + for event in invalid_logs + if event.metadata + ) + for event in invalid_logs[2:]: + assert event.metadata is not None + assert event.metadata["contract_id"] == identifier + assert event.metadata["route"] == route + assert event.metadata["provider"] == provider_name + failure_metadata = invalid_logs[3].metadata + assert failure_metadata is not None + assert failure_metadata["status"] == "ERROR" + assert failure_metadata["stage"] == "input_validation" + assert failure_metadata["failure_kind"] == "invalid_input" + assert failure_metadata["failure_summary"] == "The assessment input was invalid." + assert failure_metadata["operator_guidance"] == ( + "Correct the listed assessment fields and retry." + ) + assert failure_metadata["issues"] == [ + {"location": [provider_name, invalid_field], "type": "value_error"}, + { + "location": [provider_name, "unrecognized_field"], + "type": "extra_forbidden", + }, + ] + assert failure_metadata["issues_truncated"] is True + assert invalid_logs[3].exc_info is False + invalid_callback_metadata = invalid_logs[4].metadata + assert invalid_callback_metadata is not None + assert invalid_callback_metadata["assessment_status"] == "ERROR" + assert invalid_callback_metadata["delivery_status"] == "SUCCESS" + assert "status" not in invalid_callback_metadata + assert "attempted_status" not in invalid_callback_metadata + assert all(marker not in repr(invalid_logs) for marker in log_excluded_canaries) + + +def test_existing_check_and_service_argv_are_unchanged( + provider_forms: dict[str, dict[str, object]], +) -> None: + """Compliance selection does not repurpose either existing selector channel.""" + lifecycle: list[str] = [] + engine = _Engine(b"[]", lifecycle) + factory = ProwlerClientFactory( + _EngineFactory(engine), _CredentialLeaseFactory(lifecycle) + ) + provider = _contract("aws").parse_input(provider_forms["aws"]) + + factory.run( + ProwlerConfig(executable_path="/fake/prowler"), + provider, + check_filters=("check-one",), + ) + check_index = engine.requests[0].arguments.index("-c") + assert tuple(engine.requests[0].arguments[check_index : check_index + 2]) == ( + "-c", + "check-one", + ) + assert "--services" not in engine.requests[0].arguments + assert "--compliance" not in engine.requests[0].arguments + + second_engine = _Engine(b"[]", []) + second_factory = ProwlerClientFactory( + _EngineFactory(second_engine), _CredentialLeaseFactory([]) + ) + second_provider = _contract("aws").parse_input(provider_forms["aws"]) + second_factory.run( + ProwlerConfig(executable_path="/fake/prowler"), + second_provider, + service_selector="iam", + ) + service_index = second_engine.requests[0].arguments.index("--services") + assert tuple( + second_engine.requests[0].arguments[service_index : service_index + 2] + ) == ("--services", "iam") + assert "-c" not in second_engine.requests[0].arguments + assert "--compliance" not in second_engine.requests[0].arguments + + +def test_cross_provider_compliance_rejected_before_adapter_or_engine() -> None: + """Provider/selector pairs are validated before credentials or CLI use.""" + lifecycle: list[str] = [] + engine = _Engine(b"[]", lifecycle) + leases = _CredentialLeaseFactory(lifecycle) + factory = ProwlerClientFactory(_EngineFactory(engine), leases) + provider = AwsProviderInput( + provider="aws", + aws_access_key_id="access", + aws_secret_access_key="secret", + aws_account_id="123456789012", + aws_region="eu-west-1", + ) + + with pytest.raises(ValueError, match="compliance selector"): + factory.run( + ProwlerConfig(), + provider, + compliance_selector=cast(Any, "cis_3.0_azure"), + ) + + assert leases.calls == [] + assert engine.requests == [] + assert lifecycle == [] diff --git a/prowler/tests/unit/chk006_executable_base/test_outputs_registry_runtime.py b/prowler/tests/unit/chk006_executable_base/test_outputs_registry_runtime.py index 8954f423..d6e1f696 100644 --- a/prowler/tests/unit/chk006_executable_base/test_outputs_registry_runtime.py +++ b/prowler/tests/unit/chk006_executable_base/test_outputs_registry_runtime.py @@ -398,7 +398,7 @@ def test_runtime_start_logs_one_fixed_listener_event() -> None: assert message == _LISTENER_START assert metadata["injector_id"] == "injector-test" assert metadata["injector_name"] == "Prowler" - assert metadata["registered_contract_count"] == 11 + assert metadata["registered_contract_count"] == 15 assert metadata["configured_executable_path"] == "/usr/local/bin/prowler" assert set(metadata) == { "injector_id", @@ -1317,7 +1317,7 @@ def test_runtime_resolved_contract_uses_renderer_for_safe_error( def test_default_registry_and_daemon_config_register_executable_routes() -> None: - """CHK.013 adds two GCP services after the CHK.012 executable routes.""" + """CHK.014 adds four CIS routes after the CHK.013 executable routes.""" subject = _subject() contracts = subject.DEFAULT_PROWLER_CONTRACTS.contracts() assert [item["contract_id"] for item in contracts] == [ @@ -1332,6 +1332,10 @@ def test_default_registry_and_daemon_config_register_executable_routes() -> None str(subject.stable_contract_id("azure/storage")), str(subject.stable_contract_id("gcp/iam")), str(subject.stable_contract_id("gcp/compute")), + str(subject.stable_contract_id("cis/aws")), + str(subject.stable_contract_id("cis/azure")), + str(subject.stable_contract_id("cis/gcp")), + str(subject.stable_contract_id("cis/kubernetes")), ] daemon = _config().to_daemon_config() assert daemon.get("injector_contracts") == contracts From f5ccadbcf609785c4aa3980f73d3370a339c2ad0 Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Fri, 28 Aug 2026 10:38:57 +0200 Subject: [PATCH 3/5] feat(prowler): execute CHK.014 CIS routes (#422) --- .../prowler/_core/prowler_client/__init__.py | 2 + .../prowler/_core/prowler_client/client.py | 36 ++++++++ .../prowler/_core/prowler_client/contracts.py | 6 ++ .../prowler/_core/prowler_client/factory.py | 6 +- prowler/prowler/contracts/__init__.py | 12 +++ prowler/prowler/contracts/base.py | 34 ++++++- prowler/prowler/contracts/cis.py | 89 +++++++++++++++++++ prowler/prowler/contracts/registry.py | 5 ++ 8 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 prowler/prowler/contracts/cis.py diff --git a/prowler/prowler/_core/prowler_client/__init__.py b/prowler/prowler/_core/prowler_client/__init__.py index 46cc9e68..888f4a67 100644 --- a/prowler/prowler/_core/prowler_client/__init__.py +++ b/prowler/prowler/_core/prowler_client/__init__.py @@ -10,6 +10,7 @@ from .contracts import ( AwsServiceSelector, AzureServiceSelector, + ComplianceSelector, GcpServiceSelector, ServiceSelector, ) @@ -43,6 +44,7 @@ "OutputArtifactError", "OutputWorkspaceCleanupError", "OutputWorkspacePreparationError", + "ComplianceSelector", "AwsServiceSelector", "AzureServiceSelector", "GcpServiceSelector", diff --git a/prowler/prowler/_core/prowler_client/client.py b/prowler/prowler/_core/prowler_client/client.py index a693e1c2..34ecd8ec 100644 --- a/prowler/prowler/_core/prowler_client/client.py +++ b/prowler/prowler/_core/prowler_client/client.py @@ -16,11 +16,14 @@ AwsProviderInput, AzureProviderInput, GcpProviderInput, + ImmutableProviderInput, + KubernetesProviderInput, ProviderInput, ) from .contracts import ( CliEnginePort, + ComplianceSelector, OutputWorkspaceFactoryPort, ServiceSelector, ) @@ -79,6 +82,25 @@ def _safe_log(level: int, message: str, **metadata: object) -> None: return +_COMPLIANCE_PROVIDER_TYPES: dict[ComplianceSelector, type[ImmutableProviderInput]] = { + "cis_3.0_aws": AwsProviderInput, + "cis_3.0_azure": AzureProviderInput, + "cis_3.0_gcp": GcpProviderInput, + "cis_1.12_kubernetes": KubernetesProviderInput, +} + + +def _validate_compliance_provider( + provider: ProviderInput, compliance_selector: ComplianceSelector +) -> None: + """Reject unknown or cross-provider compliance selection before adaptation.""" + provider_type = _COMPLIANCE_PROVIDER_TYPES.get(compliance_selector) + if provider_type is None: + raise ValueError("unsupported compliance selector") + if not isinstance(provider, provider_type): + raise ValueError("compliance selector requires its matching provider") + + class ProwlerClientConsumedError(RuntimeError): """Reject reuse of a client whose provider input was already consumed.""" @@ -107,6 +129,7 @@ def run( check_filters: Sequence[str] = (), *, service_selector: ServiceSelector | None = None, + compliance_selector: ComplianceSelector | None = None, ) -> CommandResult: """Run one assessment and capture its controlled OCSF artifact.""" with self._consumption_lock: @@ -152,6 +175,13 @@ def run( raise ValueError( "IAM service selector requires an AWS, Azure, or GCP provider" ) + if compliance_selector is not None: + _validate_compliance_provider(provider, compliance_selector) + if filters or service_selector is not None: + raise ValueError( + "compliance selector cannot be combined with check or " + "service selectors" + ) _safe_log(logging.INFO, "Preparing Prowler output workspace") try: @@ -176,10 +206,16 @@ def run( service_arguments = ( ("--services", service_selector) if service_selector is not None else () ) + compliance_arguments = ( + ("--compliance", compliance_selector) + if compliance_selector is not None + else () + ) provider_and_selectors = ( *invocation.arguments, *filter_arguments, *service_arguments, + *compliance_arguments, ) narrowed = any( argument in _NARROWING_OPTIONS for argument in provider_and_selectors diff --git a/prowler/prowler/_core/prowler_client/contracts.py b/prowler/prowler/_core/prowler_client/contracts.py index 3acbef0b..49745651 100644 --- a/prowler/prowler/_core/prowler_client/contracts.py +++ b/prowler/prowler/_core/prowler_client/contracts.py @@ -13,6 +13,12 @@ AzureServiceSelector = Literal["iam", "storage"] GcpServiceSelector = Literal["iam", "compute"] ServiceSelector = AwsServiceSelector | AzureServiceSelector | GcpServiceSelector +ComplianceSelector = Literal[ + "cis_3.0_aws", + "cis_3.0_azure", + "cis_3.0_gcp", + "cis_1.12_kubernetes", +] class CliEnginePort(Protocol): diff --git a/prowler/prowler/_core/prowler_client/factory.py b/prowler/prowler/_core/prowler_client/factory.py index 2133cca2..bdad469b 100644 --- a/prowler/prowler/_core/prowler_client/factory.py +++ b/prowler/prowler/_core/prowler_client/factory.py @@ -10,6 +10,7 @@ from .client import ProwlerClient from .contracts import ( CliEngineFactoryPort, + ComplianceSelector, CredentialLeaseFactoryPort, OutputWorkspaceFactoryPort, ServiceSelector, @@ -48,8 +49,11 @@ def run( *, check_filters: Sequence[str] = (), service_selector: ServiceSelector | None = None, + compliance_selector: ComplianceSelector | None = None, ) -> CommandResult: """Create a client and synchronously run one assessment.""" return self.create(config, provider).run( - check_filters, service_selector=service_selector + check_filters, + service_selector=service_selector, + compliance_selector=compliance_selector, ) diff --git a/prowler/prowler/contracts/__init__.py b/prowler/prowler/contracts/__init__.py index 2f693f3b..b5e3bd86 100644 --- a/prowler/prowler/contracts/__init__.py +++ b/prowler/prowler/contracts/__init__.py @@ -22,6 +22,13 @@ RouteFamily, ) from .catalog import ROUTE_CATALOG, RouteDescriptor +from .cis import ( + AwsCisContract, + AzureCisContract, + CisComplianceContract, + GcpCisContract, + KubernetesCisContract, +) from .dispatcher import ContractDispatcher, RouteHandler, RouteNotFoundError from .gcp import GcpBaseContract, GcpComputeContract, GcpIamContract, GcpServiceContract from .kubernetes import KubernetesBaseContract @@ -43,11 +50,16 @@ "AzureIamContract", "AzureServiceContract", "AzureStorageContract", + "AwsCisContract", + "AzureCisContract", + "CisComplianceContract", "GcpBaseContract", "GcpComputeContract", "GcpIamContract", "GcpServiceContract", + "GcpCisContract", "KubernetesBaseContract", + "KubernetesCisContract", "ContractDispatcher", "ContractExecutionOutcome", "ContractInputError", diff --git a/prowler/prowler/contracts/base.py b/prowler/prowler/contracts/base.py index 0854fda2..ec618192 100644 --- a/prowler/prowler/contracts/base.py +++ b/prowler/prowler/contracts/base.py @@ -21,7 +21,11 @@ ) from prowler._core.cli_engine import CommandResult -from prowler._core.prowler_client import ProwlerClientFactory, ServiceSelector +from prowler._core.prowler_client import ( + ComplianceSelector, + ProwlerClientFactory, + ServiceSelector, +) from prowler.models.configs.config_loader import ProwlerConfig from prowler.models.findings import ( OcsfDecodeError, @@ -66,6 +70,7 @@ def run( *, check_filters: Sequence[str] = (), service_selector: ServiceSelector | None = None, + compliance_selector: ComplianceSelector | None = None, ) -> CommandResult: """Run one assessment and return the exact command result.""" @@ -245,6 +250,33 @@ def _execute_service( raw_preview=mapping.raw_preview, ) + def _execute_compliance( + self, + config: ProwlerConfig, + provider: ProviderInput, + compliance_selector: ComplianceSelector, + ) -> ContractExecutionOutcome: + """Run one validated compliance selector and retain provider findings.""" + result = self._client_factory.run( + config, + provider, + check_filters=self.check_filters, + compliance_selector=compliance_selector, + ) + if result.error is not None or result.return_code != 0: + return ContractExecutionOutcome(command_result=result, error=result.error) + try: + mapping = map_command_result_with_evidence(result) + except (OcsfDecodeError, OcsfMappingError) as error: + return ContractExecutionOutcome(command_result=result, error=error) + return ContractExecutionOutcome( + command_result=result, + findings=self._provider_findings(mapping.findings), + raw_record_count=mapping.raw_record_count, + raw_output_bytes=mapping.raw_output_bytes, + raw_preview=mapping.raw_preview, + ) + @staticmethod def output_trace_config() -> dict[str, object]: """Return the common flattened-field trace contract.""" diff --git a/prowler/prowler/contracts/cis.py b/prowler/prowler/contracts/cis.py new file mode 100644 index 00000000..4db1fe05 --- /dev/null +++ b/prowler/prowler/contracts/cis.py @@ -0,0 +1,89 @@ +"""Executable CIS compliance contracts for the four supported providers.""" + +from typing import ClassVar + +from prowler._core.prowler_client import ComplianceSelector +from prowler.models.configs.config_loader import ProwlerConfig +from prowler.models.provider_inputs import ProviderInput + +from .base import ( + BaseProwlerContract, + ContractExecutionOutcome, + ProviderName, + RouteFamily, +) + +_CIS_BY_PROVIDER: dict[ProviderName, ComplianceSelector] = { + "aws": "cis_3.0_aws", + "azure": "cis_3.0_azure", + "gcp": "cis_3.0_gcp", + "kubernetes": "cis_1.12_kubernetes", +} + + +class CisComplianceContract(BaseProwlerContract): + """Execute one provider-owned CIS selector through the CHK.004 seam.""" + + family: ClassVar[RouteFamily] = "compliance" + compliance_selector: ClassVar[ComplianceSelector] + + def safe_request_info(self, provider: ProviderInput | None) -> dict[str, object]: + """Identify CIS selection through safe route metadata only.""" + info = super().safe_request_info(provider) + info["filters"] = f"compliance={self.compliance_selector}" + return info + + def execute( + self, config: ProwlerConfig, provider: ProviderInput + ) -> ContractExecutionOutcome: + """Reject unsupported route metadata before one compliance client call.""" + if ( + _CIS_BY_PROVIDER.get(self.provider) != self.compliance_selector + or provider.provider != self.provider + ): + raise ValueError("unsupported CIS compliance selection") + return self._execute_compliance(config, provider, self.compliance_selector) + + +class AwsCisContract(CisComplianceContract): + """Run the Prowler 5.36 AWS CIS 3.0 framework.""" + + contract_id = "f0766dbc-b04f-5b4b-b762-0aee15884ead" + external_id = "prowler:cis/aws" + route_name = "cis/aws" + provider = "aws" + label = "Prowler AWS CIS" + compliance_selector = "cis_3.0_aws" + + +class AzureCisContract(CisComplianceContract): + """Run the Prowler 5.36 Azure CIS 3.0 framework.""" + + contract_id = "3acf796f-71a9-523a-b6dc-fca321ad3bac" + external_id = "prowler:cis/azure" + route_name = "cis/azure" + provider = "azure" + label = "Prowler Azure CIS" + compliance_selector = "cis_3.0_azure" + + +class GcpCisContract(CisComplianceContract): + """Run the Prowler 5.36 GCP CIS 3.0 framework.""" + + contract_id = "aec2729b-60c5-59e9-9383-78c5b34507cb" + external_id = "prowler:cis/gcp" + route_name = "cis/gcp" + provider = "gcp" + label = "Prowler GCP CIS" + compliance_selector = "cis_3.0_gcp" + + +class KubernetesCisContract(CisComplianceContract): + """Run the Prowler 5.36 Kubernetes CIS 1.12 framework.""" + + contract_id = "a846a50c-16b3-5df5-87cf-de279637a2e9" + external_id = "prowler:cis/kubernetes" + route_name = "cis/kubernetes" + provider = "kubernetes" + label = "Prowler Kubernetes CIS" + compliance_selector = "cis_1.12_kubernetes" diff --git a/prowler/prowler/contracts/registry.py b/prowler/prowler/contracts/registry.py index 2eb84cf9..73643461 100644 --- a/prowler/prowler/contracts/registry.py +++ b/prowler/prowler/contracts/registry.py @@ -11,6 +11,7 @@ from .azure import AzureBaseContract, AzureIamContract, AzureStorageContract from .base import BaseProwlerContract from .catalog import ROUTE_CATALOG +from .cis import AwsCisContract, AzureCisContract, GcpCisContract, KubernetesCisContract from .gcp import GcpBaseContract, GcpComputeContract, GcpIamContract from .kubernetes import KubernetesBaseContract @@ -94,5 +95,9 @@ def contracts(self) -> list[dict[str, object]]: AzureStorageContract, GcpIamContract, GcpComputeContract, + AwsCisContract, + AzureCisContract, + GcpCisContract, + KubernetesCisContract, ) ) From 1137dff3df7b281d026a88247817d601947e7797 Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Fri, 28 Aug 2026 10:42:19 +0200 Subject: [PATCH 4/5] test(prowler): align registry with CHK.014 (#422) --- .../test_chk011_service_aws_assessment_bdd.py | 6 +++++- .../test_chk012_service_azure_assessment_bdd.py | 6 +++++- .../test_chk013_service_gcp_assessment_bdd.py | 8 ++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/prowler/tests/behaviour/chk011_service_aws_assessment/test_chk011_service_aws_assessment_bdd.py b/prowler/tests/behaviour/chk011_service_aws_assessment/test_chk011_service_aws_assessment_bdd.py index 7feaf6cf..d6820825 100644 --- a/prowler/tests/behaviour/chk011_service_aws_assessment/test_chk011_service_aws_assessment_bdd.py +++ b/prowler/tests/behaviour/chk011_service_aws_assessment/test_chk011_service_aws_assessment_bdd.py @@ -95,7 +95,7 @@ def test_route_selects_exact_service_once( assert factory.calls[0][2:] == ((), service) -def test_registry_has_exact_eleven_canonical_contracts_without_selector_fields() -> ( +def test_registry_has_exact_fifteen_canonical_contracts_without_selector_fields() -> ( None ): """The public surface is ordered, stable, labelled, and not user-selectable.""" @@ -110,6 +110,10 @@ def test_registry_has_exact_eleven_canonical_contracts_without_selector_fields() "azure/storage", "gcp/iam", "gcp/compute", + "cis/aws", + "cis/azure", + "cis/gcp", + "cis/kubernetes", ) assert [item["contract_id"] for item in serialized] == [ diff --git a/prowler/tests/behaviour/chk012_service_azure_assessment/test_chk012_service_azure_assessment_bdd.py b/prowler/tests/behaviour/chk012_service_azure_assessment/test_chk012_service_azure_assessment_bdd.py index 2d2b520f..2d1ecb71 100644 --- a/prowler/tests/behaviour/chk012_service_azure_assessment/test_chk012_service_azure_assessment_bdd.py +++ b/prowler/tests/behaviour/chk012_service_azure_assessment/test_chk012_service_azure_assessment_bdd.py @@ -90,7 +90,7 @@ def test_route_selects_exact_service_once( assert factory.calls[0][2:] == ((), service) -def test_registry_has_exact_eleven_canonical_contracts_without_selector_fields() -> ( +def test_registry_has_exact_fifteen_canonical_contracts_without_selector_fields() -> ( None ): """The public surface is ordered, stable, labelled, and not user-selectable.""" @@ -106,6 +106,10 @@ def test_registry_has_exact_eleven_canonical_contracts_without_selector_fields() *(item[0] for item in _ROUTES), "gcp/iam", "gcp/compute", + "cis/aws", + "cis/azure", + "cis/gcp", + "cis/kubernetes", ) assert [item["contract_id"] for item in serialized] == [ diff --git a/prowler/tests/behaviour/chk013_service_gcp_assessment/test_chk013_service_gcp_assessment_bdd.py b/prowler/tests/behaviour/chk013_service_gcp_assessment/test_chk013_service_gcp_assessment_bdd.py index a380516c..bf8d5ab2 100644 --- a/prowler/tests/behaviour/chk013_service_gcp_assessment/test_chk013_service_gcp_assessment_bdd.py +++ b/prowler/tests/behaviour/chk013_service_gcp_assessment/test_chk013_service_gcp_assessment_bdd.py @@ -103,7 +103,7 @@ def test_selector_type_is_exactly_iam_or_compute() -> None: assert get_args(selector_type) == ("iam", "compute") -def test_registry_has_exact_eleven_canonical_contracts_without_selector_fields() -> ( +def test_registry_has_exact_fifteen_canonical_contracts_without_selector_fields() -> ( None ): """The public surface is ordered, stable, labelled, and not user-selectable.""" @@ -119,12 +119,16 @@ def test_registry_has_exact_eleven_canonical_contracts_without_selector_fields() "azure/iam", "azure/storage", *(item[0] for item in _ROUTES), + "cis/aws", + "cis/azure", + "cis/gcp", + "cis/kubernetes", ) assert [item["contract_id"] for item in serialized] == [ str(stable_contract_id(route)) for route in routes ] - for item, (route, service) in zip(serialized[9:], _ROUTES, strict=True): + for item, (route, service) in zip(serialized[9:11], _ROUTES, strict=True): content = json.loads(item["contract_content"]) assert service.casefold() in content["label"]["en"].casefold() assert tuple(field["key"] for field in content["fields"]) == ( From c4998f658541512bd369ee2d358f9a99c89d817a Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Fri, 28 Aug 2026 10:49:00 +0200 Subject: [PATCH 5/5] test(prowler): close CHK.014 registry coverage (#422) --- .../test_chk001_catalog_scaffold_bdd.py | 4 ++++ .../test_chk007_base_aws_provider_bdd.py | 4 ++++ .../test_chk008_base_azure_provider_bdd.py | 8 ++++++-- .../test_chk009_base_gcp_provider_bdd.py | 8 ++++++-- .../test_chk010_base_kubernetes_provider_bdd.py | 8 ++++++-- 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/prowler/tests/behaviour/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py b/prowler/tests/behaviour/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py index 2c2a96b3..5fe58027 100644 --- a/prowler/tests/behaviour/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py +++ b/prowler/tests/behaviour/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py @@ -90,6 +90,10 @@ def _then_base_contracts_are_registered(config: ConfigLoader, helper: Mock) -> N str(stable_contract_id("azure/storage")), str(stable_contract_id("gcp/iam")), str(stable_contract_id("gcp/compute")), + str(stable_contract_id("cis/aws")), + str(stable_contract_id("cis/azure")), + str(stable_contract_id("cis/gcp")), + str(stable_contract_id("cis/kubernetes")), ] callback = helper.listen.call_args.kwargs["message_callback"] assert callable(callback) diff --git a/prowler/tests/behaviour/chk007_base_aws_provider/test_chk007_base_aws_provider_bdd.py b/prowler/tests/behaviour/chk007_base_aws_provider/test_chk007_base_aws_provider_bdd.py index 99d17fa2..5aca02cd 100644 --- a/prowler/tests/behaviour/chk007_base_aws_provider/test_chk007_base_aws_provider_bdd.py +++ b/prowler/tests/behaviour/chk007_base_aws_provider/test_chk007_base_aws_provider_bdd.py @@ -102,6 +102,10 @@ def test_default_registration_identity_fields_and_outputs() -> None: str(stable_contract_id("azure/storage")), str(stable_contract_id("gcp/iam")), str(stable_contract_id("gcp/compute")), + str(stable_contract_id("cis/aws")), + str(stable_contract_id("cis/azure")), + str(stable_contract_id("cis/gcp")), + str(stable_contract_id("cis/kubernetes")), ] assert UUID(serialized[0]["contract_id"]) == expected_id assert expected_id.version == 5 diff --git a/prowler/tests/behaviour/chk008_base_azure_provider/test_chk008_base_azure_provider_bdd.py b/prowler/tests/behaviour/chk008_base_azure_provider/test_chk008_base_azure_provider_bdd.py index 6024e306..28631358 100644 --- a/prowler/tests/behaviour/chk008_base_azure_provider/test_chk008_base_azure_provider_bdd.py +++ b/prowler/tests/behaviour/chk008_base_azure_provider/test_chk008_base_azure_provider_bdd.py @@ -85,11 +85,11 @@ def run( def test_default_registration_identity_fields_and_outputs() -> None: - """Azure remains second as the canonical registry grows through CHK.013.""" + """Azure remains second as the canonical registry grows through CHK.014.""" serialized = DEFAULT_PROWLER_CONTRACTS.contracts() expected_id = stable_contract_id("azure") - assert len(serialized) == 11 + assert len(serialized) == 15 assert [item["contract_id"] for item in serialized] == [ str(stable_contract_id("aws")), str(expected_id), @@ -102,6 +102,10 @@ def test_default_registration_identity_fields_and_outputs() -> None: str(stable_contract_id("azure/storage")), str(stable_contract_id("gcp/iam")), str(stable_contract_id("gcp/compute")), + str(stable_contract_id("cis/aws")), + str(stable_contract_id("cis/azure")), + str(stable_contract_id("cis/gcp")), + str(stable_contract_id("cis/kubernetes")), ] assert UUID(serialized[1]["contract_id"]) == expected_id assert expected_id.version == 5 diff --git a/prowler/tests/behaviour/chk009_base_gcp_provider/test_chk009_base_gcp_provider_bdd.py b/prowler/tests/behaviour/chk009_base_gcp_provider/test_chk009_base_gcp_provider_bdd.py index 0aba7473..4e07cdc1 100644 --- a/prowler/tests/behaviour/chk009_base_gcp_provider/test_chk009_base_gcp_provider_bdd.py +++ b/prowler/tests/behaviour/chk009_base_gcp_provider/test_chk009_base_gcp_provider_bdd.py @@ -89,11 +89,11 @@ def run( def test_default_registration_identity_fields_and_outputs() -> None: - """GCP remains third as the canonical registry grows through CHK.013.""" + """GCP remains third as the canonical registry grows through CHK.014.""" serialized = DEFAULT_PROWLER_CONTRACTS.contracts() expected_id = stable_contract_id("gcp") - assert len(serialized) == 11 + assert len(serialized) == 15 assert [item["contract_id"] for item in serialized] == [ str(stable_contract_id("aws")), str(stable_contract_id("azure")), @@ -106,6 +106,10 @@ def test_default_registration_identity_fields_and_outputs() -> None: str(stable_contract_id("azure/storage")), str(stable_contract_id("gcp/iam")), str(stable_contract_id("gcp/compute")), + str(stable_contract_id("cis/aws")), + str(stable_contract_id("cis/azure")), + str(stable_contract_id("cis/gcp")), + str(stable_contract_id("cis/kubernetes")), ] assert UUID(serialized[2]["contract_id"]) == expected_id assert expected_id.version == 5 diff --git a/prowler/tests/behaviour/chk010_base_kubernetes_provider/test_chk010_base_kubernetes_provider_bdd.py b/prowler/tests/behaviour/chk010_base_kubernetes_provider/test_chk010_base_kubernetes_provider_bdd.py index 27551e91..f0f679a2 100644 --- a/prowler/tests/behaviour/chk010_base_kubernetes_provider/test_chk010_base_kubernetes_provider_bdd.py +++ b/prowler/tests/behaviour/chk010_base_kubernetes_provider/test_chk010_base_kubernetes_provider_bdd.py @@ -89,11 +89,11 @@ def run( def test_default_registration_identity_fields_and_outputs() -> None: - """The four base routes remain first in the CHK.013 executable surface.""" + """The four base routes remain first in the CHK.014 executable surface.""" serialized = DEFAULT_PROWLER_CONTRACTS.contracts() expected_id = stable_contract_id("kubernetes") - assert len(serialized) == 11 + assert len(serialized) == 15 assert [item["contract_id"] for item in serialized] == [ str(stable_contract_id("aws")), str(stable_contract_id("azure")), @@ -106,6 +106,10 @@ def test_default_registration_identity_fields_and_outputs() -> None: str(stable_contract_id("azure/storage")), str(stable_contract_id("gcp/iam")), str(stable_contract_id("gcp/compute")), + str(stable_contract_id("cis/aws")), + str(stable_contract_id("cis/azure")), + str(stable_contract_id("cis/gcp")), + str(stable_contract_id("cis/kubernetes")), ] assert UUID(serialized[3]["contract_id"]) == expected_id assert expected_id.version == 5