From 204870f48aa748d00c88dfecd22aa81d2c59c7ba Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Tue, 2 Jun 2026 20:23:44 +0200 Subject: [PATCH] fix(compliance): merge frameworks from multiple dynamic packages per provider _get_ep_compliance_dirs collapsed compliance directories into a dict keyed by provider, so when two or more dynamically-registered packages shipped compliance for the same provider, only one survived and the others were silently dropped from discovery, default output modes and the stdout table. Accumulate the directories per provider and iterate them all in get_available_compliance_frameworks, matching the behaviour Compliance.get_bulk already had. Built-in precedence and filename-stem dedup are preserved. --- prowler/config/config.py | 23 ++++++++++++++++------- tests/config/config_test.py | 28 +++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/prowler/config/config.py b/prowler/config/config.py index 10e63c42df..cae0aee85f 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -87,15 +87,22 @@ actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) def _get_ep_compliance_dirs() -> dict: - """Discover compliance directories from entry points. Returns {provider: path}.""" + """Discover compliance directories from entry points. Returns {provider: [paths]}. + + A provider may be contributed by several packages, so accumulate every + directory instead of overwriting. + """ dirs = {} for ep in importlib.metadata.entry_points(group="prowler.compliance"): try: module = ep.load() if hasattr(module, "__path__"): - dirs[ep.name] = module.__path__[0] + path = module.__path__[0] elif hasattr(module, "__file__"): - dirs[ep.name] = os.path.dirname(module.__file__) + path = os.path.dirname(module.__file__) + else: + continue + dirs.setdefault(ep.name, []).append(path) except Exception as error: logger.warning( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -144,13 +151,15 @@ def get_available_compliance_frameworks(provider=None): continue if name not in available_compliance_frameworks: available_compliance_frameworks.append(name) - # External compliance via entry points. - # Multi-provider support for external plug-ins is tracked in PROWLER-1444. + # External compliance via entry points; a provider may be served by + # several packages, so iterate every directory it contributes. ep_dirs = _get_ep_compliance_dirs() - for prov, path in ep_dirs.items(): + for prov, paths in ep_dirs.items(): if provider and prov != provider: continue - if os.path.isdir(path): + for path in paths: + if not os.path.isdir(path): + continue for file in os.scandir(path): if file.is_file() and file.name.endswith(".json"): name = file.name.removesuffix(".json") diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 2a7aecd330..365efbc0c9 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -488,7 +488,7 @@ class Test_Config: with open(json_path, "w") as f: json.dump({"Framework": "CIS", "Provider": "aws"}, f) - mock_dirs.return_value = {"aws": tmpdir} + mock_dirs.return_value = {"aws": [tmpdir]} frameworks = get_available_compliance_frameworks("aws") @@ -497,6 +497,32 @@ class Test_Config: f"{frameworks.count('cis_2.0_aws')} occurrences in: {frameworks}" ) + @mock.patch("prowler.config.config._get_ep_compliance_dirs") + def test_get_available_compliance_frameworks_merges_multiple_ep_dirs_same_provider( + self, mock_dirs + ): + """Frameworks from every package contributing the same provider must + surface, not just the last directory discovered.""" + import json + import tempfile + + with ( + tempfile.TemporaryDirectory() as pkg_a, + tempfile.TemporaryDirectory() as pkg_b, + ): + with open(os.path.join(pkg_a, "cis_1.0_template.json"), "w") as f: + json.dump({"Framework": "CIS", "Provider": "template"}, f) + with open(os.path.join(pkg_b, "nis2_1.0_template.json"), "w") as f: + json.dump({"Framework": "NIS2", "Provider": "template"}, f) + + # Two packages register `prowler.compliance` with the same name. + mock_dirs.return_value = {"template": [pkg_a, pkg_b]} + + frameworks = get_available_compliance_frameworks("template") + + assert "cis_1.0_template" in frameworks + assert "nis2_1.0_template" in frameworks + def test_load_and_validate_config_file_aws(self): path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) config_test_file = f"{path}/fixtures/config.yaml"