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.
This commit is contained in:
StylusFrost committed 2026-06-02 20:23:44 +02:00
1 parent d25f8a4fe9
commit 204870f48a
2 files changed
+43 -8

No files matched your search

+16 -7
View File
@@ -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")
+27 -1
View File
@@ -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"