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"