From ca6ca8f1e35007db2ee6ff735739e02541cc8c68 Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Wed, 26 Aug 2026 21:06:46 +0200 Subject: [PATCH 1/4] test(prowler): define catalog scaffold behavior (#422) --- .../chk001_catalog_scaffold.feature | 17 ++++ .../test_chk001_catalog_scaffold_bdd.py | 80 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 prowler/tests/chk001_catalog_scaffold/chk001_catalog_scaffold.feature create mode 100644 prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py diff --git a/prowler/tests/chk001_catalog_scaffold/chk001_catalog_scaffold.feature b/prowler/tests/chk001_catalog_scaffold/chk001_catalog_scaffold.feature new file mode 100644 index 00000000..cb98c713 --- /dev/null +++ b/prowler/tests/chk001_catalog_scaffold/chk001_catalog_scaffold.feature @@ -0,0 +1,17 @@ +Feature: Prowler catalog registration and project scaffold + Scenario: Discoverable Prowler catalog registration + Given a Prowler injector project is available + When its catalog registration and configuration are completed + Then Prowler is discoverable in the OpenAEV catalog + And its available configuration is clearly identified + + # ---- Constraints identified ---- + Scenario: Foundation configuration excludes future provider settings + Given CHK.001 owns only the injector configuration foundation + When the available configuration is inspected + Then only the standard OpenAEV and injector settings are present + + Scenario: Foundation startup registers no assessment contracts + Given assessment contracts are deferred to CHK.006 + When the Prowler injector starts + Then it registers with an empty contract catalog diff --git a/prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py b/prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py new file mode 100644 index 00000000..1e0327ad --- /dev/null +++ b/prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py @@ -0,0 +1,80 @@ +"""Behavior tests for the CHK.001 Prowler catalog scaffold.""" + +import importlib +import json +from pathlib import Path +from unittest.mock import Mock + +PROJECT_ROOT = Path(__file__).parents[2] +STANDARD_ENV_SETTINGS = { + "OPENAEV_URL", + "OPENAEV_TOKEN", + "OPENAEV_TENANT_ID", + "INJECTOR_ID", + "INJECTOR_NAME", + "INJECTOR_LOG_LEVEL", +} + + +def _given_the_prowler_project() -> Path: + assert (PROJECT_ROOT / "prowler").is_dir(), "Prowler package scaffold is absent" + return PROJECT_ROOT + + +def _when_manifest_is_loaded(project_root: Path) -> dict[str, object]: + manifest_path = project_root / "manifest-metadata.json" + assert manifest_path.is_file(), "Prowler catalog manifest is absent" + return json.loads(manifest_path.read_text(encoding="utf-8")) + + +def _then_manifest_identifies_prowler(manifest: dict[str, object]) -> None: + assert manifest["title"] == "Prowler" + assert manifest["slug"] == "openaev_prowler" + assert manifest["container_image"] == "openaev/injector-prowler" + assert manifest["container_type"] == "INJECTOR" + + +def _when_sample_environment_is_loaded(project_root: Path) -> set[str]: + sample_path = project_root / ".env.sample" + assert sample_path.is_file(), "Prowler environment sample is absent" + return { + line.partition("=")[0] + for raw_line in sample_path.read_text(encoding="utf-8").splitlines() + if (line := raw_line.strip()) and not line.startswith("#") and "=" in line + } + + +def _then_only_standard_settings_are_available(settings: set[str]) -> None: + assert settings == STANDARD_ENV_SETTINGS + + +def _when_injector_starts() -> tuple[Mock, Mock]: + module = importlib.import_module("prowler.injector.openaev_prowler") + config = Mock() + helper = Mock() + injector = module.ProwlerInjector(config=config, helper=helper) + injector.start() + return config, helper + + +def _then_zero_contracts_are_registered(config: Mock, helper: Mock) -> None: + assert config.to_daemon_config().get("injector_contracts") == [] + helper.listen.assert_called_once_with() + + +def test_discoverable_prowler_catalog_registration() -> None: + project_root = _given_the_prowler_project() + manifest = _when_manifest_is_loaded(project_root) + _then_manifest_identifies_prowler(manifest) + + +def test_foundation_configuration_excludes_future_provider_settings() -> None: + project_root = _given_the_prowler_project() + settings = _when_sample_environment_is_loaded(project_root) + _then_only_standard_settings_are_available(settings) + + +def test_foundation_startup_registers_no_assessment_contracts() -> None: + _given_the_prowler_project() + config, helper = _when_injector_starts() + _then_zero_contracts_are_registered(config, helper) From c4828b0b8d6a0fe05a7a02a0ba5cbe46c6793124 Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Wed, 26 Aug 2026 21:12:10 +0200 Subject: [PATCH 2/4] feat(prowler): add catalog registration scaffold (#422) --- prowler/.env.sample | 10 +++ prowler/.gitignore | 10 +++ prowler/README.md | 28 ++++++++ prowler/config.yml.sample | 10 +++ prowler/manifest-metadata.json | 18 ++++++ prowler/prowler/__init__.py | 1 + prowler/prowler/__main__.py | 38 +++++++++++ prowler/prowler/injector/__init__.py | 5 ++ prowler/prowler/injector/openaev_prowler.py | 23 +++++++ prowler/prowler/models/__init__.py | 5 ++ prowler/prowler/models/configs/__init__.py | 5 ++ .../prowler/models/configs/config_loader.py | 42 ++++++++++++ prowler/pyproject.toml | 64 +++++++++++++++++++ .../test_chk001_catalog_scaffold_bdd.py | 38 ++++++++--- 14 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 prowler/.env.sample create mode 100644 prowler/.gitignore create mode 100644 prowler/README.md create mode 100644 prowler/config.yml.sample create mode 100644 prowler/manifest-metadata.json create mode 100644 prowler/prowler/__init__.py create mode 100644 prowler/prowler/__main__.py create mode 100644 prowler/prowler/injector/__init__.py create mode 100644 prowler/prowler/injector/openaev_prowler.py create mode 100644 prowler/prowler/models/__init__.py create mode 100644 prowler/prowler/models/configs/__init__.py create mode 100644 prowler/prowler/models/configs/config_loader.py create mode 100644 prowler/pyproject.toml diff --git a/prowler/.env.sample b/prowler/.env.sample new file mode 100644 index 00000000..30a08961 --- /dev/null +++ b/prowler/.env.sample @@ -0,0 +1,10 @@ +# OpenAEV connection +OPENAEV_URL=ChangeMe +OPENAEV_TOKEN=ChangeMe +# Optional for multi-tenancy (Enterprise Edition) +OPENAEV_TENANT_ID=ChangeMe + +# Injector identity and logging +INJECTOR_ID=ChangeMe +INJECTOR_NAME=Prowler +INJECTOR_LOG_LEVEL=error diff --git a/prowler/.gitignore b/prowler/.gitignore new file mode 100644 index 00000000..70af39af --- /dev/null +++ b/prowler/.gitignore @@ -0,0 +1,10 @@ +config.yml +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +build/ +dist/ +*.egg-info/ diff --git a/prowler/README.md b/prowler/README.md new file mode 100644 index 00000000..67a36038 --- /dev/null +++ b/prowler/README.md @@ -0,0 +1,28 @@ +# OpenAEV Prowler Injector + +The Prowler injector foundation registers Prowler with OpenAEV. Assessment +providers, credentials, execution, mapping, routes, and contracts are outside +CHK.001 and are intentionally not configured here. + +## Configuration + +| Environment variable | Configuration key | Purpose | +|---|---|---| +| `OPENAEV_URL` | `openaev.url` | OpenAEV server URL | +| `OPENAEV_TOKEN` | `openaev.token` | OpenAEV API token | +| `OPENAEV_TENANT_ID` | `openaev.tenant_id` | Optional tenant identifier | +| `INJECTOR_ID` | `injector.id` | Unique injector identifier | +| `INJECTOR_NAME` | `injector.name` | Injector display name | +| `INJECTOR_LOG_LEVEL` | `injector.log_level` | Runtime log level | + +Copy `config.yml.sample` to the ignored `config.yml` for local use, or supply +the equivalent environment variables. Never commit real tokens. + +## Run + +```shell +python -m prowler +``` + +The CHK.001 foundation starts with zero assessment contracts. Contract catalog +registration is deferred to CHK.006. diff --git a/prowler/config.yml.sample b/prowler/config.yml.sample new file mode 100644 index 00000000..91497583 --- /dev/null +++ b/prowler/config.yml.sample @@ -0,0 +1,10 @@ +openaev: + url: 'ChangeMe' + token: 'ChangeMe' + # Optional for multi-tenancy (Enterprise Edition) + tenant_id: 'ChangeMe' + +injector: + id: 'ChangeMe' + name: 'Prowler' + log_level: 'error' diff --git a/prowler/manifest-metadata.json b/prowler/manifest-metadata.json new file mode 100644 index 00000000..f121800f --- /dev/null +++ b/prowler/manifest-metadata.json @@ -0,0 +1,18 @@ +{ + "title": "Prowler", + "slug": "openaev_prowler", + "description": "Run Prowler cloud security assessments from OpenAEV.", + "short_description": "Run cloud security assessments with Prowler", + "use_cases": ["Technical"], + "verified": false, + "last_verified_date": "", + "playbook_supported": false, + "max_confidence_level": 80, + "support_version": "", + "subscription_link": "", + "source_code": "", + "manager_supported": true, + "container_version": "rolling", + "container_image": "openaev/injector-prowler", + "container_type": "INJECTOR" +} diff --git a/prowler/prowler/__init__.py b/prowler/prowler/__init__.py new file mode 100644 index 00000000..e28ed113 --- /dev/null +++ b/prowler/prowler/__init__.py @@ -0,0 +1 @@ +"""Prowler injector package.""" diff --git a/prowler/prowler/__main__.py b/prowler/prowler/__main__.py new file mode 100644 index 00000000..7795f4c6 --- /dev/null +++ b/prowler/prowler/__main__.py @@ -0,0 +1,38 @@ +"""Main entry point for the Prowler injector.""" + +import logging +import sys + +from pydantic import ValidationError +from pyoaev.helpers import OpenAEVConfigHelper, OpenAEVInjectorHelper + +from prowler.injector import ProwlerInjector +from prowler.models import ConfigLoader + +LOG_PREFIX = "[PROWLER_MAIN]" + + +def main() -> None: + """Load configuration and start the injector.""" + logger = logging.getLogger(__name__) + try: + config = ConfigLoader() + helper = OpenAEVInjectorHelper( + config=OpenAEVConfigHelper.from_configuration_object( + config.to_daemon_config() + ), + icon=None, + ) + ProwlerInjector(config=config, helper=helper).start() + except ValidationError as error: + logger.error("%s Configuration error: %s", LOG_PREFIX, error) + sys.exit(2) + except KeyboardInterrupt: + logger.info("%s Injector stopped by user", LOG_PREFIX) + except Exception as error: + logger.exception("%s Fatal startup error: %s", LOG_PREFIX, error) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/prowler/prowler/injector/__init__.py b/prowler/prowler/injector/__init__.py new file mode 100644 index 00000000..edc02adb --- /dev/null +++ b/prowler/prowler/injector/__init__.py @@ -0,0 +1,5 @@ +"""Prowler injector runtime.""" + +from prowler.injector.openaev_prowler import ProwlerInjector + +__all__ = ["ProwlerInjector"] diff --git a/prowler/prowler/injector/openaev_prowler.py b/prowler/prowler/injector/openaev_prowler.py new file mode 100644 index 00000000..7d98b92f --- /dev/null +++ b/prowler/prowler/injector/openaev_prowler.py @@ -0,0 +1,23 @@ +"""OpenAEV runtime boundary for the Prowler injector.""" + +from pyoaev.helpers import OpenAEVInjectorHelper + +from prowler.models import ConfigLoader + + +class ProwlerInjector: + """Register the foundation injector without assessment contracts.""" + + def __init__(self, config: ConfigLoader, helper: OpenAEVInjectorHelper) -> None: + """Initialize the injector with its configuration and helper.""" + self.config = config + self.helper = helper + + def start(self) -> None: + """Start the injector listener after zero-contract registration.""" + self.config.to_daemon_config() + self.helper.listen(message_callback=self.process_message) + + def process_message(self, _data: dict[str, object]) -> None: + """Reject execution because CHK.001 registers no contracts.""" + raise RuntimeError("Prowler assessment contracts are not registered") diff --git a/prowler/prowler/models/__init__.py b/prowler/prowler/models/__init__.py new file mode 100644 index 00000000..2292b3f3 --- /dev/null +++ b/prowler/prowler/models/__init__.py @@ -0,0 +1,5 @@ +"""Prowler injector models.""" + +from prowler.models.configs import ConfigLoader + +__all__ = ["ConfigLoader"] diff --git a/prowler/prowler/models/configs/__init__.py b/prowler/prowler/models/configs/__init__.py new file mode 100644 index 00000000..ecfcb562 --- /dev/null +++ b/prowler/prowler/models/configs/__init__.py @@ -0,0 +1,5 @@ +"""Prowler injector configuration models.""" + +from prowler.models.configs.config_loader import ConfigLoader + +__all__ = ["ConfigLoader"] diff --git a/prowler/prowler/models/configs/config_loader.py b/prowler/prowler/models/configs/config_loader.py new file mode 100644 index 00000000..81ef30e2 --- /dev/null +++ b/prowler/prowler/models/configs/config_loader.py @@ -0,0 +1,42 @@ +"""Configuration foundation for the Prowler injector.""" + +from pydantic import Field +from pyoaev.configuration import ( + ConfigLoaderCollector, + ConfigLoaderOAEV, + Configuration, + SettingsLoader, +) + + +class InjectorConfig(ConfigLoaderCollector): + """Standard injector identity and logging settings.""" + + name: str = Field(default="Prowler", description="Name of the injector.") + icon_filepath: str | None = Field( + default=None, description="Optional path to an injector icon." + ) + + +class ConfigLoader(SettingsLoader): + """Load only the standard OpenAEV and injector settings.""" + + openaev: ConfigLoaderOAEV = Field(default_factory=ConfigLoaderOAEV) + injector: InjectorConfig = Field(default_factory=InjectorConfig) + + def to_daemon_config(self) -> Configuration: + """Translate settings into the OpenAEV daemon configuration.""" + return Configuration( + config_hints={ + "openaev_url": {"data": str(self.openaev.url)}, + "openaev_token": {"data": self.openaev.token}, + "openaev_tenant_id": {"data": getattr(self.openaev, "tenant_id", None)}, + "injector_id": {"data": self.injector.id}, + "injector_name": {"data": self.injector.name}, + "injector_type": {"data": "openaev_prowler"}, + "injector_contracts": {"data": []}, + "injector_log_level": {"data": self.injector.log_level}, + "injector_icon_filepath": {"data": self.injector.icon_filepath}, + }, + config_base_model=self, + ) diff --git a/prowler/pyproject.toml b/prowler/pyproject.toml new file mode 100644 index 00000000..d3b1d715 --- /dev/null +++ b/prowler/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "openaev-prowler-injector" +version = "0.1.0" +description = "OpenAEV injector for Prowler cloud security assessments" +readme = "README.md" +authors = [{ name = "Filigran", email = "contact@filigran.io" }] +license = "Apache-2.0" +requires-python = ">=3.11,<4.0" +dependencies = [ + "pyoaev==2.3.5", + "pydantic>=2.11.3,<2.14.0", + "pydantic-settings>=2.11.0,<2.15.0", +] + +[project.scripts] +ProwlerInjector = "prowler.__main__:main" + +[project.optional-dependencies] +dev = [ + "black>=26.3.1,<26.4.0", + "build>=1.4.0,<1.5.0", + "isort>=8.0.1,<8.1.0", + "mypy~=1.18.2", + "pytest>=9.0.0,<9.1.0", + "ruff~=0.14.2", +] + +[tool.poetry] +packages = [{ include = "prowler" }] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.isort] +profile = "black" +src_paths = ["prowler"] + +[tool.ruff] +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "D", "B", "I", "S"] +ignore = ["D104", "D203", "D211", "D213"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "S106"] + +[tool.mypy] +strict = true +plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = ["prowler.__main__", "prowler.injector.*"] +# pyoaev 2.3.5 does not publish a py.typed marker or external stubs. +disable_error_code = ["import-untyped"] + +[[tool.mypy.overrides]] +module = ["prowler.models.configs.config_loader"] +# These two pyoaev base classes are consequently exposed to mypy as Any. +disable_error_code = ["import-untyped", "misc"] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py b/prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py index 1e0327ad..61a868d0 100644 --- a/prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py +++ b/prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py @@ -1,10 +1,15 @@ """Behavior tests for the CHK.001 Prowler catalog scaffold.""" -import importlib import json from pathlib import Path +from typing import cast from unittest.mock import Mock +import pytest + +from prowler.injector.openaev_prowler import ProwlerInjector +from prowler.models.configs.config_loader import ConfigLoader + PROJECT_ROOT = Path(__file__).parents[2] STANDARD_ENV_SETTINGS = { "OPENAEV_URL", @@ -24,7 +29,9 @@ def _given_the_prowler_project() -> Path: def _when_manifest_is_loaded(project_root: Path) -> dict[str, object]: manifest_path = project_root / "manifest-metadata.json" assert manifest_path.is_file(), "Prowler catalog manifest is absent" - return json.loads(manifest_path.read_text(encoding="utf-8")) + return cast( + dict[str, object], json.loads(manifest_path.read_text(encoding="utf-8")) + ) def _then_manifest_identifies_prowler(manifest: dict[str, object]) -> None: @@ -48,33 +55,44 @@ def _then_only_standard_settings_are_available(settings: set[str]) -> None: assert settings == STANDARD_ENV_SETTINGS -def _when_injector_starts() -> tuple[Mock, Mock]: - module = importlib.import_module("prowler.injector.openaev_prowler") - config = Mock() +def _when_injector_starts(monkeypatch: pytest.MonkeyPatch) -> tuple[ConfigLoader, Mock]: + monkeypatch.setenv("OPENAEV_URL", "http://localhost:8080") + monkeypatch.setenv("OPENAEV_TOKEN", "test-token") + monkeypatch.setenv("OPENAEV_TENANT_ID", "test-tenant") + monkeypatch.setenv("INJECTOR_ID", "test-injector") + monkeypatch.setenv("INJECTOR_NAME", "Prowler") + monkeypatch.setenv("INJECTOR_LOG_LEVEL", "debug") + config = ConfigLoader() helper = Mock() - injector = module.ProwlerInjector(config=config, helper=helper) + injector = ProwlerInjector(config=config, helper=helper) injector.start() return config, helper -def _then_zero_contracts_are_registered(config: Mock, helper: Mock) -> None: +def _then_zero_contracts_are_registered(config: ConfigLoader, helper: Mock) -> None: assert config.to_daemon_config().get("injector_contracts") == [] - helper.listen.assert_called_once_with() + callback = helper.listen.call_args.kwargs["message_callback"] + assert callable(callback) def test_discoverable_prowler_catalog_registration() -> None: + """Prowler is represented by a discoverable catalog manifest.""" project_root = _given_the_prowler_project() manifest = _when_manifest_is_loaded(project_root) _then_manifest_identifies_prowler(manifest) def test_foundation_configuration_excludes_future_provider_settings() -> None: + """The foundation exposes only standard injector settings.""" project_root = _given_the_prowler_project() settings = _when_sample_environment_is_loaded(project_root) _then_only_standard_settings_are_available(settings) -def test_foundation_startup_registers_no_assessment_contracts() -> None: +def test_foundation_startup_registers_no_assessment_contracts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The foundation starts its listener with an empty contract catalog.""" _given_the_prowler_project() - config, helper = _when_injector_starts() + config, helper = _when_injector_starts(monkeypatch) _then_zero_contracts_are_registered(config, helper) From edd4ce763f6838f4a8f03af0ec5090e6148248d7 Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Wed, 26 Aug 2026 21:15:49 +0200 Subject: [PATCH 3/4] refactor(prowler): separate behavioural and unit tests (#422) --- prowler/tests/__init__.py | 0 prowler/tests/behaviour/__init__.py | 0 .../chk001_catalog_scaffold/__init__.py | 0 .../chk001_catalog_scaffold.feature | 0 .../chk001_catalog_scaffold/conftest.py | 14 ++++++++++++++ .../test_chk001_catalog_scaffold_bdd.py | 18 +++++------------- prowler/tests/unit/__init__.py | 0 7 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 prowler/tests/__init__.py create mode 100644 prowler/tests/behaviour/__init__.py create mode 100644 prowler/tests/behaviour/chk001_catalog_scaffold/__init__.py rename prowler/tests/{ => behaviour}/chk001_catalog_scaffold/chk001_catalog_scaffold.feature (100%) create mode 100644 prowler/tests/behaviour/chk001_catalog_scaffold/conftest.py rename prowler/tests/{ => behaviour}/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py (82%) create mode 100644 prowler/tests/unit/__init__.py diff --git a/prowler/tests/__init__.py b/prowler/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/prowler/tests/behaviour/__init__.py b/prowler/tests/behaviour/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/prowler/tests/behaviour/chk001_catalog_scaffold/__init__.py b/prowler/tests/behaviour/chk001_catalog_scaffold/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/prowler/tests/chk001_catalog_scaffold/chk001_catalog_scaffold.feature b/prowler/tests/behaviour/chk001_catalog_scaffold/chk001_catalog_scaffold.feature similarity index 100% rename from prowler/tests/chk001_catalog_scaffold/chk001_catalog_scaffold.feature rename to prowler/tests/behaviour/chk001_catalog_scaffold/chk001_catalog_scaffold.feature diff --git a/prowler/tests/behaviour/chk001_catalog_scaffold/conftest.py b/prowler/tests/behaviour/chk001_catalog_scaffold/conftest.py new file mode 100644 index 00000000..b78e5e51 --- /dev/null +++ b/prowler/tests/behaviour/chk001_catalog_scaffold/conftest.py @@ -0,0 +1,14 @@ +"""Fixtures local to the CHK.001 catalog scaffold behaviour tests.""" + +import pytest + + +@pytest.fixture +def standard_injector_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """Configure the standard injector environment owned by CHK.001.""" + monkeypatch.setenv("OPENAEV_URL", "http://localhost:8080") + monkeypatch.setenv("OPENAEV_TOKEN", "test-token") + monkeypatch.setenv("OPENAEV_TENANT_ID", "test-tenant") + monkeypatch.setenv("INJECTOR_ID", "test-injector") + monkeypatch.setenv("INJECTOR_NAME", "Prowler") + monkeypatch.setenv("INJECTOR_LOG_LEVEL", "debug") diff --git a/prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py b/prowler/tests/behaviour/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py similarity index 82% rename from prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py rename to prowler/tests/behaviour/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py index 61a868d0..813a30f2 100644 --- a/prowler/tests/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py +++ b/prowler/tests/behaviour/chk001_catalog_scaffold/test_chk001_catalog_scaffold_bdd.py @@ -1,16 +1,14 @@ -"""Behavior tests for the CHK.001 Prowler catalog scaffold.""" +"""Behaviour tests for the CHK.001 Prowler catalog scaffold.""" import json from pathlib import Path from typing import cast from unittest.mock import Mock -import pytest - from prowler.injector.openaev_prowler import ProwlerInjector from prowler.models.configs.config_loader import ConfigLoader -PROJECT_ROOT = Path(__file__).parents[2] +PROJECT_ROOT = Path(__file__).parents[3] STANDARD_ENV_SETTINGS = { "OPENAEV_URL", "OPENAEV_TOKEN", @@ -55,13 +53,7 @@ def _then_only_standard_settings_are_available(settings: set[str]) -> None: assert settings == STANDARD_ENV_SETTINGS -def _when_injector_starts(monkeypatch: pytest.MonkeyPatch) -> tuple[ConfigLoader, Mock]: - monkeypatch.setenv("OPENAEV_URL", "http://localhost:8080") - monkeypatch.setenv("OPENAEV_TOKEN", "test-token") - monkeypatch.setenv("OPENAEV_TENANT_ID", "test-tenant") - monkeypatch.setenv("INJECTOR_ID", "test-injector") - monkeypatch.setenv("INJECTOR_NAME", "Prowler") - monkeypatch.setenv("INJECTOR_LOG_LEVEL", "debug") +def _when_injector_starts() -> tuple[ConfigLoader, Mock]: config = ConfigLoader() helper = Mock() injector = ProwlerInjector(config=config, helper=helper) @@ -90,9 +82,9 @@ def test_foundation_configuration_excludes_future_provider_settings() -> None: def test_foundation_startup_registers_no_assessment_contracts( - monkeypatch: pytest.MonkeyPatch, + standard_injector_environment: None, ) -> None: """The foundation starts its listener with an empty contract catalog.""" _given_the_prowler_project() - config, helper = _when_injector_starts(monkeypatch) + config, helper = _when_injector_starts() _then_zero_contracts_are_registered(config, helper) diff --git a/prowler/tests/unit/__init__.py b/prowler/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b From 122a95abdeeee93e7eaf775542ae612eab1ec1c1 Mon Sep 17 00:00:00 2001 From: Christophe Melchior Date: Wed, 26 Aug 2026 21:18:52 +0200 Subject: [PATCH 4/4] chore(prowler): add safe startup logging (#422) --- prowler/prowler/__main__.py | 8 +- .../chk001_catalog_scaffold.feature | 11 +++ .../test_chk001_catalog_scaffold_bdd.py | 74 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/prowler/prowler/__main__.py b/prowler/prowler/__main__.py index 7795f4c6..0e6c56bf 100644 --- a/prowler/prowler/__main__.py +++ b/prowler/prowler/__main__.py @@ -24,13 +24,13 @@ def main() -> None: icon=None, ) ProwlerInjector(config=config, helper=helper).start() - except ValidationError as error: - logger.error("%s Configuration error: %s", LOG_PREFIX, error) + except ValidationError: + logger.error("[PROWLER_MAIN] Configuration error") sys.exit(2) except KeyboardInterrupt: logger.info("%s Injector stopped by user", LOG_PREFIX) - except Exception as error: - logger.exception("%s Fatal startup error: %s", LOG_PREFIX, error) + except Exception: + logger.error("[PROWLER_MAIN] Fatal startup error") sys.exit(1) diff --git a/prowler/tests/behaviour/chk001_catalog_scaffold/chk001_catalog_scaffold.feature b/prowler/tests/behaviour/chk001_catalog_scaffold/chk001_catalog_scaffold.feature index cb98c713..0e00c017 100644 --- a/prowler/tests/behaviour/chk001_catalog_scaffold/chk001_catalog_scaffold.feature +++ b/prowler/tests/behaviour/chk001_catalog_scaffold/chk001_catalog_scaffold.feature @@ -15,3 +15,14 @@ Feature: Prowler catalog registration and project scaffold Given assessment contracts are deferred to CHK.006 When the Prowler injector starts Then it registers with an empty contract catalog + + Scenario Outline: Startup failures are logged without sensitive exception details + Given startup fails with a containing a sensitive canary + When the Prowler injector handles the startup failure + Then it emits a safe ERROR log without the exception or a traceback + And it exits with status + + Examples: + | failure type | exit status | + | configuration error | 2 | + | unexpected exception | 1 | 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 813a30f2..22bd633e 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 @@ -1,10 +1,15 @@ """Behaviour tests for the CHK.001 Prowler catalog scaffold.""" import json +import logging from pathlib import Path from typing import cast from unittest.mock import Mock +import pytest +from pydantic import BaseModel, ValidationError + +from prowler import __main__ as prowler_main from prowler.injector.openaev_prowler import ProwlerInjector from prowler.models.configs.config_loader import ConfigLoader @@ -17,6 +22,13 @@ "INJECTOR_NAME", "INJECTOR_LOG_LEVEL", } +VALIDATION_CANARY = "PYDANTIC_VALIDATION_CANARY" +UNEXPECTED_CANARY = "UNEXPECTED_EXCEPTION_CANARY" +SENSITIVE_CANARIES = (VALIDATION_CANARY, UNEXPECTED_CANARY) + + +class _IntegerSetting(BaseModel): + value: int def _given_the_prowler_project() -> Path: @@ -67,6 +79,50 @@ def _then_zero_contracts_are_registered(config: ConfigLoader, helper: Mock) -> N assert callable(callback) +def _given_startup_failure(failure_type: str) -> Exception: + if failure_type == "configuration error": + try: + _IntegerSetting(value=VALIDATION_CANARY) + except ValidationError as error: + return error + raise AssertionError("Validation canary did not trigger a configuration error") + return RuntimeError(UNEXPECTED_CANARY) + + +def _when_startup_failure_is_handled( + error: Exception, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> tuple[int, list[logging.LogRecord]]: + monkeypatch.setattr(prowler_main, "ConfigLoader", Mock(side_effect=error)) + + with caplog.at_level(logging.DEBUG, logger=prowler_main.__name__): + with pytest.raises(SystemExit) as raised: + prowler_main.main() + + return cast(int, raised.value.code), list(caplog.records) + + +def _then_failure_is_logged_safely( + actual_exit_status: int, + expected_exit_status: int, + records: list[logging.LogRecord], +) -> None: + assert actual_exit_status == expected_exit_status + error_records = [record for record in records if record.levelno == logging.ERROR] + assert len(error_records) == 1 + + for record in records: + assert record.exc_info is None + assert record.exc_text is None + log_surfaces = (record.getMessage(), str(record.msg), repr(record.args)) + assert all( + canary not in surface + for canary in SENSITIVE_CANARIES + for surface in log_surfaces + ) + + def test_discoverable_prowler_catalog_registration() -> None: """Prowler is represented by a discoverable catalog manifest.""" project_root = _given_the_prowler_project() @@ -88,3 +144,21 @@ def test_foundation_startup_registers_no_assessment_contracts( _given_the_prowler_project() config, helper = _when_injector_starts() _then_zero_contracts_are_registered(config, helper) + + +@pytest.mark.parametrize( + ("failure_type", "expected_exit_status"), + (("configuration error", 2), ("unexpected exception", 1)), +) +def test_startup_failures_are_logged_without_sensitive_exception_details( + failure_type: str, + expected_exit_status: int, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Startup failures preserve safe logs and established exit statuses.""" + error = _given_startup_failure(failure_type) + actual_exit_status, records = _when_startup_failure_is_handled( + error, monkeypatch, caplog + ) + _then_failure_is_logged_safely(actual_exit_status, expected_exit_status, records)