From b255ed6e73997c0c2360625d37070741ffe8373c Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Thu, 27 Aug 2026 17:28:10 +0200 Subject: [PATCH 1/3] test(prowler): define CHK.008 Azure base behaviour (#422) --- .../chk008_base_azure_provider/__init__.py | 1 + .../chk008_base_azure_provider.feature | 42 ++ .../chk008_base_azure_provider/conftest.py | 111 +++++ .../test_chk008_base_azure_provider_bdd.py | 466 ++++++++++++++++++ .../test_outputs_registry_runtime.py | 2 +- 5 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 prowler/tests/behaviour/chk008_base_azure_provider/__init__.py create mode 100644 prowler/tests/behaviour/chk008_base_azure_provider/chk008_base_azure_provider.feature create mode 100644 prowler/tests/behaviour/chk008_base_azure_provider/conftest.py create mode 100644 prowler/tests/behaviour/chk008_base_azure_provider/test_chk008_base_azure_provider_bdd.py diff --git a/prowler/tests/behaviour/chk008_base_azure_provider/__init__.py b/prowler/tests/behaviour/chk008_base_azure_provider/__init__.py new file mode 100644 index 00000000..232b29ae --- /dev/null +++ b/prowler/tests/behaviour/chk008_base_azure_provider/__init__.py @@ -0,0 +1 @@ +"""CHK.008 Azure base-provider behaviour tests.""" diff --git a/prowler/tests/behaviour/chk008_base_azure_provider/chk008_base_azure_provider.feature b/prowler/tests/behaviour/chk008_base_azure_provider/chk008_base_azure_provider.feature new file mode 100644 index 00000000..35c655dc --- /dev/null +++ b/prowler/tests/behaviour/chk008_base_azure_provider/chk008_base_azure_provider.feature @@ -0,0 +1,42 @@ +Feature: CHK.008 Azure base provider + The registered Azure route validates local input, requests the complete Azure + provider scope once, and emits deterministic mapped Azure findings. + + Scenario: The default registry exposes the stable Azure contract + Given the canonical routes "aws" and "azure" + When the default contracts are serialized + Then exactly two contracts are registered in canonical order + And the Azure UUIDv5 and external ID are stable + And it inherits the Azure fields and shared outputs + + Scenario: Invalid Azure input is rejected locally + Given a blank tenant, client, secret, subscription, or provider value + When the Azure contract parses the form + Then it rejects the request without authenticating or dispatching Prowler + + Scenario: Valid Azure input requests the complete provider scope + Given complete Azure service-principal credentials and target details + When the Azure contract executes + Then the client is invoked exactly once + And the command contains the Azure authentication, subscription, provider, and OCSF arguments + And no service, check, or compliance narrowing is present + + Scenario: Only normalized Azure findings are emitted + Given ordered OCSF records for Azure and other cloud providers + When a successful command result is mapped + Then only case-normalized Azure findings are retained in source order + And every retained finding preserves all 14 mapped fields + And text output contains every finding while vulnerability output contains only failures + + Scenario: OpenAEV runtime success is completed end to end + Given a fake Prowler client and a valid Azure injection + When the injector receives the message + Then reception occurs before one SUCCESS callback + And structured output and the dynamic Rich trace contain the same mapped findings + And credentials and process internals are absent + + Scenario: OpenAEV runtime failure is safe + Given an invalid Azure injection containing secret canaries + When the injector receives the message + Then no Prowler request is made + And one ERROR callback contains no secret canary diff --git a/prowler/tests/behaviour/chk008_base_azure_provider/conftest.py b/prowler/tests/behaviour/chk008_base_azure_provider/conftest.py new file mode 100644 index 00000000..69e66b0a --- /dev/null +++ b/prowler/tests/behaviour/chk008_base_azure_provider/conftest.py @@ -0,0 +1,111 @@ +"""Local deterministic CHK.008 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 azure_form() -> dict[str, object]: + """Return structurally valid placeholder-only Azure form input.""" + return { + "azure_tenant_id": "CANARY-TENANT", + "azure_client_id": "CANARY-CLIENT", + "azure_client_secret": "CANARY-SECRET", + "azure_subscription_id": "subscription-123", + "azure_provider": "Microsoft.Compute", + } + + +@pytest.fixture +def azure_ocsf_record_factory() -> Any: + """Build one complete minimal OCSF record for projection tests.""" + + def build( + title: str, + *, + provider: str = "azure", + status: str = "FAIL", + ) -> 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 + return { + "finding_info": { + "uid": f"check-{title}", + "title": title, + "desc": f"Description {title}", + }, + "status": record_status, + "status_code": status_code, + "severity": "High", + "resources": [{"uid": f"asset-{title}", "name": f"Asset {title}"}], + "cloud": { + "provider": provider, + "region": "westeurope", + "account": {"uid": "subscription-123"}, + }, + "unmapped": {"compliance": ["cis", "nis2"]}, + "remediation": { + "desc": f"Remediate {title}", + "references": ["https://example.invalid/remediation"], + }, + } + + return build 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 new file mode 100644 index 00000000..99e228ff --- /dev/null +++ b/prowler/tests/behaviour/chk008_base_azure_provider/test_chk008_base_azure_provider_bdd.py @@ -0,0 +1,466 @@ +"""Raw pytest executable contract for CHK.008.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any +from uuid import UUID + +import pytest +from pyoaev.configuration import ConfigLoaderOAEV + +from prowler._core.cli_engine import ( + CommandResult, + ExecutionSpecification, + ValidatedCommandRequest, +) +from prowler._core.prowler_client import ProwlerClientFactory +from prowler.contracts import DEFAULT_PROWLER_CONTRACTS, stable_contract_id +from prowler.models.configs.config_loader import ( + ConfigLoader, + InjectorConfig, + ProwlerConfig, +) +from prowler.models.findings import OpenAevFinding + +from .conftest import RecordingLogger + +_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 _contract() -> Any: + try: + return DEFAULT_PROWLER_CONTRACTS.resolve(str(stable_contract_id("azure"))) + except LookupError: + pytest.fail("CHK.008 Azure base contract is not registered") + + +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 _specification(arguments: tuple[str, ...] = ()) -> ExecutionSpecification: + return ExecutionSpecification( + executable="/fake/prowler", + arguments=arguments, + environment=(), + working_directory=None, + input_bytes=b"", + output=__import__( + "prowler._core.cli_engine", fromlist=["OutputSpecification"] + ).OutputSpecification(parser="raw"), + timeout_seconds=1.0, + maximum_accepted_output_bytes=1024, + ) + + +@dataclass +class _ClientFactory: + result: CommandResult + calls: list[tuple[Any, Any, tuple[str, ...]]] = field(default_factory=list) + + def run( + self, config: Any, provider: Any, *, check_filters: Any = () + ) -> CommandResult: + self.calls.append((config, provider, tuple(check_filters))) + return self.result + + +def test_default_registration_identity_fields_and_outputs() -> None: + """The default surface is exactly canonical AWS then Azure base routes.""" + serialized = DEFAULT_PROWLER_CONTRACTS.contracts() + expected_id = stable_contract_id("azure") + + assert len(serialized) == 2 + assert [item["contract_id"] for item in serialized] == [ + str(stable_contract_id("aws")), + str(expected_id), + ] + assert UUID(serialized[1]["contract_id"]) == expected_id + assert expected_id.version == 5 + content = json.loads(serialized[1]["contract_content"]) + assert content["external_id"] == "prowler:azure" + assert tuple(field["key"] for field in content["fields"]) == ( + "azure_tenant_id", + "azure_client_id", + "azure_client_secret", + "azure_subscription_id", + "azure_provider", + ) + assert tuple(output["field"] for output in content["outputs"]) == ( + "findings", + "vulnerabilities", + ) + + +@pytest.mark.parametrize( + "field_name", + ( + "azure_tenant_id", + "azure_client_id", + "azure_client_secret", + "azure_subscription_id", + "azure_provider", + ), +) +def test_structurally_blank_input_is_rejected_before_dispatch( + azure_form: dict[str, object], field_name: str +) -> None: + """Inherited Azure validation remains structural, local, and pre-dispatch.""" + factory = _ClientFactory(CommandResult(specification=_specification())) + contract = _contract() + contract._client_factory = factory + + with pytest.raises(ValueError): + contract.parse_input({**azure_form, field_name: " \t"}) + + assert factory.calls == [] + + +def test_valid_request_invokes_client_once_without_narrowing( + azure_form: dict[str, object], azure_ocsf_record_factory: Any +) -> None: + """The concrete base route passes the complete Azure scope once.""" + result = CommandResult( + specification=_specification(), + return_code=0, + stdout=json.dumps([azure_ocsf_record_factory("one")]).encode(), + ) + factory = _ClientFactory(result) + contract = _contract() + contract._client_factory = factory + + outcome = contract.execute(ProwlerConfig(), contract.parse_input(azure_form)) + + assert len(factory.calls) == 1 + assert factory.calls[0][2] == () + assert len(outcome.findings) == 1 + + +@dataclass +class _Engine: + payload: bytes + requests: list[ValidatedCommandRequest] = field(default_factory=list) + + def run(self, request: ValidatedCommandRequest) -> CommandResult: + self.requests.append(request) + return CommandResult( + specification=ExecutionSpecification.from_request(request), + return_code=0, + stdout=self.payload, + ) + + +@dataclass +class _EngineFactory: + engine: _Engine + calls: int = 0 + + def create(self) -> _Engine: + self.calls += 1 + return self.engine + + +@dataclass +class _NoCredentialLeaseFactory: + calls: int = 0 + + def create(self, secret: Any, *, suffix: str) -> Any: + del secret, suffix + self.calls += 1 + raise AssertionError("Azure must not create a temporary credential file") + + +def test_fake_engine_proves_exact_azure_subprocess_arguments( + azure_form: dict[str, object], azure_ocsf_record_factory: Any +) -> None: + """The real client composition emits exact non-shell full-scope argv.""" + engine = _Engine(json.dumps([azure_ocsf_record_factory("argv")]).encode()) + engine_factory = _EngineFactory(engine) + leases = _NoCredentialLeaseFactory() + contract = _contract() + contract._client_factory = ProwlerClientFactory(engine_factory, leases) + + contract.execute( + ProwlerConfig(executable_path="/fake/prowler"), + contract.parse_input(azure_form), + ) + + assert engine_factory.calls == 1 + assert len(engine.requests) == 1 + request = engine.requests[0] + assert tuple(request.arguments) == ( + "azure", + "--sp-env-auth", + "--subscription-id", + "subscription-123", + "--azure-region", + "Microsoft.Compute", + "-M", + "json-ocsf", + ) + assert tuple(name for name, _ in request.environment) == ( + "AZURE_TENANT_ID", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + ) + assert not {"-c", "--service", "--services", "--compliance"}.intersection( + request.arguments + ) + assert leases.calls == 0 + + +def test_mapping_filters_normalizes_preserves_order_and_projects_outputs( + azure_form: dict[str, object], azure_ocsf_record_factory: Any +) -> None: + """CHK.005 mappings feed ordered Azure, Text, and FAILED output projections.""" + records = [ + azure_ocsf_record_factory("first", provider="Azure", status="PASS"), + azure_ocsf_record_factory("excluded", provider="AWS", status="FAIL"), + azure_ocsf_record_factory("second", provider="azure", status="FAIL"), + ] + factory = _ClientFactory( + CommandResult( + specification=_specification(), + return_code=0, + stdout=json.dumps(records).encode(), + ) + ) + contract = _contract() + contract._client_factory = factory + + findings = contract.execute( + ProwlerConfig(), contract.parse_input(azure_form) + ).findings + payload = contract.output_payload(findings) + + assert tuple(item.value for item in findings) == ("first", "second") + assert tuple(item.cloud_provider for item in findings) == ("azure", "azure") + assert tuple(item.expectation_result for item in findings) == ( + "SUCCESS", + "FAILED", + ) + assert all( + tuple(item.model_dump()) == tuple(OpenAevFinding.model_fields) + for item in findings + ) + assert tuple(json.loads(item)["value"] for item in payload["findings"]) == ( + "first", + "second", + ) + assert tuple(item["name"] for item in payload["vulnerabilities"]) == ("second",) + + +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 _message(identifier: str, content: dict[str, object]) -> dict[str, object]: + return { + "injection": { + "inject_id": "inject-chk008", + "injector_contract_id": identifier, + "inject_content": content, + } + } + + +def test_runtime_success_and_safe_error_are_end_to_end( + azure_form: dict[str, object], azure_ocsf_record_factory: Any +) -> None: + """Real extraction, mapping, Rich output, and terminal callbacks are exercised.""" + from prowler.injector import ProwlerInjector + + canaries = ( + "CANARY-TENANT", + "CANARY-CLIENT", + "CANARY-SECRET", + "/tmp/credential-canary", # noqa: S108 - deliberate leak canary + "STDERR-CANARY", + ) + records = [ + azure_ocsf_record_factory("runtime-pass", status="PASS"), + azure_ocsf_record_factory("runtime-fail", status="FAIL"), + azure_ocsf_record_factory("excluded", provider="aws", status="FAIL"), + ] + result = CommandResult( + specification=_specification( + ("/tmp/credential-canary",) # noqa: S108 - deliberate leak canary + ), + return_code=0, + stdout=json.dumps(records).encode(), + stderr=b"STDERR-CANARY", + ) + factory = _ClientFactory(result) + contract = _contract() + contract._client_factory = factory + helper = _Helper() + injector = ProwlerInjector(_config(), helper, registry=DEFAULT_PROWLER_CONTRACTS) + identifier = str(stable_contract_id("azure")) + + injector.process_message(_message(identifier, azure_form)) + + events = helper.api.inject.events + assert tuple(event[0] for event in events) == ("reception", "callback") + callback = events[1][2] + assert callback["execution_status"] == "SUCCESS" + structured = json.loads(callback["execution_output_structured"]) + mapped_names = tuple(json.loads(item)["value"] for item in structured["findings"]) + assert mapped_names == ("runtime-pass", "runtime-fail") + assert tuple(item["name"] for item in structured["vulnerabilities"]) == ( + "runtime-fail", + ) + assert all(name in callback["execution_message"] for name in mapped_names) + assert "excluded" not in callback["execution_message"] + assert all(marker not in json.dumps(callback) for marker in canaries) + assert len(factory.calls) == 1 + 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) + assert all( + event.metadata["inject_id"] == "inject-chk008" + for event in logs + if event.metadata + ) + assert all( + 0 <= event.metadata["elapsed_ms"] <= 86_400_000 + for event in logs + if event.metadata + ) + for event in logs[2:]: + assert event.metadata is not None + assert event.metadata["contract_id"] == identifier + assert event.metadata["route"] == "azure" + assert event.metadata["provider"] == "azure" + for event in logs[3:]: + assert event.metadata is not None + assert event.metadata["azure_tenant_id_present"] is True + assert event.metadata["azure_client_id_present"] is True + assert event.metadata["azure_client_secret_present"] is True + assert event.metadata["azure_subscription_id"] == "subscription-123" + assert event.metadata["azure_provider"] == "Microsoft.Compute" + 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 + 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 + serialized_logs = repr(logs) + assert all(marker not in serialized_logs for marker in canaries) + + invalid_helper = _Helper() + invalid_injector = ProwlerInjector( + _config(), invalid_helper, registry=DEFAULT_PROWLER_CONTRACTS + ) + invalid_injector.process_message( + _message(identifier, {**azure_form, "azure_subscription_id": " "}) + ) + 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 canaries) + assert len(factory.calls) == 1 + 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-chk008" + 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"] == "azure" + assert event.metadata["provider"] == "azure" + 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"] == ( + "Azure subscription ID has an invalid value." + ) + assert failure_metadata["issues"] == [ + { + "location": ["azure", "azure_subscription_id"], + "type": "value_error", + } + ] + 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 + serialized_invalid_logs = repr(invalid_logs) + assert all(marker not in serialized_invalid_logs for marker in canaries) 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 42929054..b9daa067 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"] == 1 + assert metadata["registered_contract_count"] == 2 assert metadata["configured_executable_path"] == "/usr/local/bin/prowler" assert set(metadata) == { "injector_id", From c6ac835da43c19c08d569989f03ab5ce60a59ff0 Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Thu, 27 Aug 2026 17:34:10 +0200 Subject: [PATCH 2/3] feat(prowler): execute CHK.008 Azure base contract (#422) --- prowler/prowler/contracts/__init__.py | 2 + prowler/prowler/contracts/azure.py | 35 +++++++++++++ prowler/prowler/contracts/registry.py | 3 +- .../test_chk008_base_azure_provider_bdd.py | 50 ++++++++++++++++--- 4 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 prowler/prowler/contracts/azure.py diff --git a/prowler/prowler/contracts/__init__.py b/prowler/prowler/contracts/__init__.py index fd23e90b..917461cb 100644 --- a/prowler/prowler/contracts/__init__.py +++ b/prowler/prowler/contracts/__init__.py @@ -1,6 +1,7 @@ """Prowler contract declarations and the executable default registry.""" from .aws import AwsBaseContract +from .azure import AzureBaseContract from .base import ( BaseProwlerContract, ContractExecutionOutcome, @@ -21,6 +22,7 @@ __all__ = [ "BaseProwlerContract", "AwsBaseContract", + "AzureBaseContract", "ContractDispatcher", "ContractExecutionOutcome", "ContractInputError", diff --git a/prowler/prowler/contracts/azure.py b/prowler/prowler/contracts/azure.py new file mode 100644 index 00000000..df39df4b --- /dev/null +++ b/prowler/prowler/contracts/azure.py @@ -0,0 +1,35 @@ +"""Executable CHK.008 complete-scope Azure base contract.""" + +from dataclasses import replace +from typing import ClassVar + +from prowler.models.configs.config_loader import ProwlerConfig +from prowler.models.provider_inputs import ProviderInput + +from .base import BaseProwlerContract, ContractExecutionOutcome + + +class AzureBaseContract(BaseProwlerContract): + """Run the complete Azure provider scope and retain mapped Azure findings.""" + + contract_id: ClassVar[str] = "00558d49-06ee-5a6f-80e6-4dae205be992" + external_id: ClassVar[str] = "prowler:azure" + route_name: ClassVar[str] = "azure" + provider = "azure" + family = "base" + label = "Prowler Azure" + check_filters = () + + def execute( + self, config: ProwlerConfig, provider: ProviderInput + ) -> ContractExecutionOutcome: + """Map one complete-scope result, preserving ordered Azure findings only.""" + outcome = super().execute(config, provider) + if outcome.error is not None or outcome.command_result.return_code != 0: + return outcome + findings = tuple( + finding.model_copy(update={"cloud_provider": "azure"}) + for finding in outcome.findings + if finding.cloud_provider.casefold() == "azure" + ) + return replace(outcome, findings=findings) diff --git a/prowler/prowler/contracts/registry.py b/prowler/prowler/contracts/registry.py index 020c1ddc..fd232d06 100644 --- a/prowler/prowler/contracts/registry.py +++ b/prowler/prowler/contracts/registry.py @@ -8,6 +8,7 @@ from pyoaev.contracts.contract_config import prepare_contracts from .aws import AwsBaseContract +from .azure import AzureBaseContract from .base import BaseProwlerContract from .catalog import ROUTE_CATALOG @@ -78,4 +79,4 @@ def contracts(self) -> list[dict[str, object]]: ) -DEFAULT_PROWLER_CONTRACTS = ProwlerContracts((AwsBaseContract,)) +DEFAULT_PROWLER_CONTRACTS = ProwlerContracts((AwsBaseContract, AzureBaseContract)) 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 99e228ff..a6b2966c 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 @@ -4,6 +4,7 @@ import json from dataclasses import dataclass, field +from pathlib import Path from typing import Any from uuid import UUID @@ -15,7 +16,10 @@ ExecutionSpecification, ValidatedCommandRequest, ) -from prowler._core.prowler_client import ProwlerClientFactory +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, @@ -135,10 +139,12 @@ def test_valid_request_invokes_client_once_without_narrowing( azure_form: dict[str, object], azure_ocsf_record_factory: Any ) -> None: """The concrete base route passes the complete Azure scope once.""" + artifact = json.dumps([azure_ocsf_record_factory("one")]).encode() result = CommandResult( specification=_specification(), return_code=0, - stdout=json.dumps([azure_ocsf_record_factory("one")]).encode(), + stdout=b"\x1b[32mconsole output is not OCSF JSON\x1b[0m", + parsed=artifact, ) factory = _ClientFactory(result) contract = _contract() @@ -149,6 +155,9 @@ def test_valid_request_invokes_client_once_without_narrowing( assert len(factory.calls) == 1 assert factory.calls[0][2] == () assert len(outcome.findings) == 1 + assert outcome.raw_record_count == 1 + assert outcome.raw_output_bytes == len(artifact) + assert len(outcome.raw_preview) == 1 @dataclass @@ -158,10 +167,13 @@ class _Engine: def run(self, request: ValidatedCommandRequest) -> CommandResult: 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) return CommandResult( specification=ExecutionSpecification.from_request(request), return_code=0, - stdout=self.payload, + stdout=b"\x1b[32mconsole output is not OCSF JSON\x1b[0m", ) @@ -210,6 +222,19 @@ def test_fake_engine_proves_exact_azure_subprocess_arguments( "subscription-123", "--azure-region", "Microsoft.Compute", + "--severity", + "critical", + "high", + "medium", + "low", + "informational", + "--output-directory", + request.arguments[request.arguments.index("--output-directory") + 1], + "--output-filename", + "findings", + "-z", + "--only-logs", + "--no-color", "-M", "json-ocsf", ) @@ -233,11 +258,13 @@ def test_mapping_filters_normalizes_preserves_order_and_projects_outputs( azure_ocsf_record_factory("excluded", provider="AWS", status="FAIL"), azure_ocsf_record_factory("second", provider="azure", status="FAIL"), ] + artifact = json.dumps(records).encode() factory = _ClientFactory( CommandResult( specification=_specification(), return_code=0, - stdout=json.dumps(records).encode(), + stdout=b"\x1b[32mconsole output is not OCSF JSON\x1b[0m", + parsed=artifact, ) ) contract = _contract() @@ -305,19 +332,22 @@ def test_runtime_success_and_safe_error_are_end_to_end( "CANARY-SECRET", "/tmp/credential-canary", # noqa: S108 - deliberate leak canary "STDERR-CANARY", + "CONSOLE-NON-JSON-CANARY", ) records = [ azure_ocsf_record_factory("runtime-pass", status="PASS"), azure_ocsf_record_factory("runtime-fail", status="FAIL"), azure_ocsf_record_factory("excluded", provider="aws", status="FAIL"), ] + artifact = json.dumps(records).encode() result = CommandResult( specification=_specification( ("/tmp/credential-canary",) # noqa: S108 - deliberate leak canary ), return_code=0, - stdout=json.dumps(records).encode(), + stdout=b"\x1b[31mCONSOLE-NON-JSON-CANARY\x1b[0m", stderr=b"STDERR-CANARY", + parsed=artifact, ) factory = _ClientFactory(result) contract = _contract() @@ -339,7 +369,13 @@ def test_runtime_success_and_safe_error_are_end_to_end( "runtime-fail", ) assert all(name in callback["execution_message"] for name in mapped_names) - assert "excluded" not 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] + assert callback["execution_message"].index("Prowler Findings") < raw_section_index + assert "Total raw records: 3" in callback["execution_message"] + assert f"Artifact bytes: {len(artifact)}" in callback["execution_message"] assert all(marker not in json.dumps(callback) for marker in canaries) assert len(factory.calls) == 1 logs = helper.injector_logger.events @@ -389,6 +425,8 @@ def test_runtime_success_and_safe_error_are_end_to_end( 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(artifact) callback_metadata = logs[6].metadata assert callback_metadata is not None assert callback_metadata["assessment_status"] == "SUCCESS" From 85c788cbf8780a8304b4693cc1361c052f4c8436 Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Thu, 27 Aug 2026 17:45:47 +0200 Subject: [PATCH 3/3] test(prowler): align registry expectations with CHK.008 (#422) --- .../test_chk001_catalog_scaffold_bdd.py | 15 +++++++++------ .../test_chk007_base_aws_provider_bdd.py | 7 +++++-- .../test_outputs_registry_runtime.py | 10 ++++++---- 3 files changed, 20 insertions(+), 12 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 53ec219a..b973e2aa 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 @@ -75,10 +75,13 @@ def _when_injector_starts() -> tuple[ConfigLoader, Mock]: return config, helper -def _then_aws_contract_is_registered(config: ConfigLoader, helper: Mock) -> None: +def _then_base_contracts_are_registered(config: ConfigLoader, helper: Mock) -> None: contracts = config.to_daemon_config().get("injector_contracts") - assert isinstance(contracts, list) and len(contracts) == 1 - assert contracts[0]["contract_id"] == str(stable_contract_id("aws")) + assert isinstance(contracts, list) + assert [item["contract_id"] for item in contracts] == [ + str(stable_contract_id("aws")), + str(stable_contract_id("azure")), + ] callback = helper.listen.call_args.kwargs["message_callback"] assert callable(callback) @@ -141,13 +144,13 @@ def test_foundation_configuration_excludes_future_provider_settings() -> None: _then_only_standard_and_prowler_settings_are_available(settings) -def test_startup_registers_the_aws_assessment_contract( +def test_startup_registers_the_base_assessment_contracts( standard_injector_environment: None, ) -> None: - """CHK.007 starts its listener with the AWS base contract.""" + """CHK.008 starts its listener with canonical AWS and Azure contracts.""" _given_the_prowler_project() config, helper = _when_injector_starts() - _then_aws_contract_is_registered(config, helper) + _then_base_contracts_are_registered(config, helper) @pytest.mark.parametrize( 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 51399df8..d97f64aa 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 @@ -86,11 +86,14 @@ def run( def test_default_registration_identity_fields_and_outputs() -> None: - """The executable default surface is exactly the canonical AWS base route.""" + """The canonical AWS base route remains first in the executable surface.""" serialized = DEFAULT_PROWLER_CONTRACTS.contracts() expected_id = stable_contract_id("aws") - assert len(serialized) == 1 + assert [item["contract_id"] for item in serialized] == [ + str(stable_contract_id("aws")), + str(stable_contract_id("azure")), + ] assert UUID(serialized[0]["contract_id"]) == expected_id assert expected_id.version == 5 content = json.loads(serialized[0]["contract_content"]) 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 b9daa067..7be9473c 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 @@ -1316,11 +1316,13 @@ def test_runtime_resolved_contract_uses_renderer_for_safe_error( assert "SECRET-MARKER" not in callback["execution_message"] -def test_default_registry_and_daemon_config_register_aws_only() -> None: - """CHK.007 makes AWS the sole default executable contract.""" +def test_default_registry_and_daemon_config_register_base_routes() -> None: + """CHK.008 makes AWS and Azure the canonical executable contracts.""" subject = _subject() contracts = subject.DEFAULT_PROWLER_CONTRACTS.contracts() - assert len(contracts) == 1 - assert contracts[0]["contract_id"] == str(subject.stable_contract_id("aws")) + assert [item["contract_id"] for item in contracts] == [ + str(subject.stable_contract_id("aws")), + str(subject.stable_contract_id("azure")), + ] daemon = _config().to_daemon_config() assert daemon.get("injector_contracts") == contracts