Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
f8333ba
feat: Enhance dynamic provider loading and compliance framework disco…
StylusFrost Apr 15, 2026
484211b
fix(sdk): align exception handlers to SDK convention and improve test…
StylusFrost Apr 21, 2026
5f10e1c
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost Apr 21, 2026
6f6016d
chore: update CHANGELOG for Prowler v5.25.0 with new features
StylusFrost Apr 21, 2026
e273174
feat(external-provider): add dynamic loading tests and coverage for e…
StylusFrost Apr 21, 2026
e2295bd
feat(provider): implement get_mutelist_finding_args for external prov…
StylusFrost Apr 21, 2026
3deb135
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost Apr 21, 2026
f60f7c6
feat(provider): add display_compliance_table method for provider-spec…
StylusFrost Apr 21, 2026
9c056be
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost Apr 22, 2026
e8487d0
fix(sdk): unwrap namespaced config for all built-in and external prov…
StylusFrost Apr 24, 2026
60e7657
feat(sdk): wire is_external_tool_provider property to execution and m…
StylusFrost Apr 24, 2026
cf70d1f
fix(sdk): honor from_cli_args return value in init_global_provider fa…
StylusFrost Apr 24, 2026
0883baa
fix(sdk): external providers with --service and external checks for n…
StylusFrost Apr 24, 2026
907166d
fix(sdk): discriminate builtin vs external providers via find_spec fo…
StylusFrost Apr 24, 2026
a31fe9b
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost Apr 24, 2026
1cdce02
fix(sdk): use startswith("-") to detect CLI flags so external provide…
StylusFrost Apr 24, 2026
a5de660
fix(sdk): restore llm in parser usage line to match epilog
StylusFrost Apr 28, 2026
52f6653
fix(sdk): use equality not substring in provider dispatch chain
StylusFrost Apr 28, 2026
7836905
fix(sdk): consult Provider.is_tool_wrapper_provider in check discovery
StylusFrost Apr 28, 2026
45e946c
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost Apr 28, 2026
6715361
fix(sdk): restore dynamic external providers help in CLI epilog
StylusFrost Apr 28, 2026
79f12f3
refactor(sdk): extract is_tool_wrapper_provider to leaf module to bre…
StylusFrost Apr 28, 2026
15d8f16
test(sdk): unit tests for tool_wrapper leaf module
StylusFrost Apr 28, 2026
be49fd8
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost Apr 28, 2026
5e87657
fix(sdk): use is_tool_wrapper_provider for compliance framework gate
StylusFrost Apr 30, 2026
82132a9
fix(sdk): use find_spec to distinguish missing vs broken built-ins
StylusFrost Apr 30, 2026
e7f23bb
fix(sdk): propagate provider argument from report to stdout_report
StylusFrost Apr 30, 2026
c7aa536
fix(sdk): built-in wins on plug-in collision for providers and checks
StylusFrost Apr 30, 2026
92d7ea2
Merge remote-tracking branch 'origin/master' into PROWLER-1391-provid…
StylusFrost May 3, 2026
0672c80
fix(sdk): guard find_spec with is_builtin for external provider disco…
StylusFrost May 3, 2026
bbe3a7d
refactor(sdk): extract is_builtin_provider to leaf module to break im…
StylusFrost May 3, 2026
9681901
style(sdk): satisfy black and vulture in test_dynamic_provider_loading
StylusFrost May 3, 2026
cf99e02
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost May 5, 2026
0203888
fix(sdk): silence CodeQL py/not-named-self on CheckMetadata validators
StylusFrost May 6, 2026
e5b9fee
fix(sdk): dedupe entry-point compliance frameworks against built-ins
StylusFrost May 7, 2026
4fb14bb
perf(sdk): cache misses in Provider._load_ep_provider
StylusFrost May 7, 2026
b13baa9
refactor(sdk): scope ImageBaseException catch to image provider in __…
StylusFrost May 7, 2026
ca72922
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost May 27, 2026
b3c0f78
style(sdk): remove trailing whitespace on blank lines
StylusFrost May 27, 2026
b25a8e5
ci(sdk): switch external provider tests from poetry to uv
StylusFrost May 27, 2026
03cacb8
fix(sdk): gate external mutelist delegate to non-builtin providers
StylusFrost May 27, 2026
a6ae490
docs(sdk): move #10700 changelog entries to 5.29.0 unreleased
StylusFrost May 31, 2026
e1ade76
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost May 31, 2026
c1e1317
fix(sdk): sync CLI parser provider list with available built-ins
StylusFrost May 31, 2026
4682345
docs(sdk): move #10700 changelog entries to 5.30.0 unreleased
StylusFrost May 31, 2026
5070ce3
fix(sdk): guard built-in providers in is_tool_wrapper_provider
StylusFrost Jun 1, 2026
64e8268
fix(sdk): detect shadowed provider plug-ins without loading them
StylusFrost Jun 1, 2026
9c7afd6
fix(sdk): match compliance provider segment exactly in get_bulk
StylusFrost Jun 1, 2026
b7b5565
Merge remote-tracking branch 'origin/master' into PROWLER-1391-provid…
StylusFrost Jun 1, 2026
38788b7
Merge remote-tracking branch 'origin/master' into PROWLER-1391-provid…
StylusFrost Jun 3, 2026
efa3283
fix(provider): return generic OutputOptions default instead of raising
StylusFrost Jun 5, 2026
8bc8b16
fix(provider): avoid import cycle in get_output_options default
StylusFrost Jun 5, 2026
356e6e2
fix(provider): default get_summary_entity instead of raising
StylusFrost Jun 5, 2026
29825f9
fix(provider): move get_output_options default to call site
StylusFrost Jun 5, 2026
f9682c1
fix(compliance): make GenericCompliance tolerant of provider-specific…
StylusFrost Jun 5, 2026
f729c5a
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
StylusFrost Jun 5, 2026
8a0d567
fix(changelog): resolve leftover merge conflict marker
StylusFrost Jun 5, 2026
40da359
feat(compliance): discover external universal frameworks via entry po…
StylusFrost Jun 7, 2026
d7346a6
docs(changelog): add entry for external universal compliance via entr…
StylusFrost Jun 7, 2026
ae70d02
Merge remote-tracking branch 'origin/master' into PROWLER-1444-multi-…
StylusFrost Jun 8, 2026
cd65963
Merge branch 'master' into PROWLER-1444-multi-provider-compliance-ent…
StylusFrost Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions prowler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490)
- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)

Expand Down
29 changes: 27 additions & 2 deletions prowler/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,7 @@ 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 per-provider compliance via entry points.
ep_dirs = _get_ep_compliance_dirs()
for prov, path in ep_dirs.items():
if provider and prov != provider:
Expand All @@ -156,6 +155,32 @@ def get_available_compliance_frameworks(provider=None):
name = file.name.removesuffix(".json")
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
# External multi-provider frameworks via the dedicated universal group;
# filtered by supports_provider when a provider is given.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
try:
module = ep.load()
path = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
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")
if provider:
framework = load_compliance_framework_universal(file.path)
if framework is None or not framework.supports_provider(provider):
continue
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
return available_compliance_frameworks


Expand Down
57 changes: 45 additions & 12 deletions prowler/lib/check/compliance_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,9 +478,15 @@ def get_bulk(provider: str) -> dict:
compliance_framework_name
not in bulk_compliance_frameworks
):
bulk_compliance_frameworks[
compliance_framework_name
] = load_compliance_framework(file_path)
# External JSON: tolerate non-legacy
# schemas (skip + warn) instead of aborting.
framework = load_compliance_framework(
file_path, fatal=False
)
if framework is not None:
bulk_compliance_frameworks[
compliance_framework_name
] = framework
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
Expand All @@ -494,18 +500,26 @@ def get_bulk(provider: str) -> dict:

# Testing Pending
def load_compliance_framework(
compliance_specification_file: str,
) -> Compliance:
"""load_compliance_framework loads and parse a Compliance Framework Specification"""
compliance_specification_file: str, fatal: bool = True
) -> Optional[Compliance]:
"""load_compliance_framework loads and parse a Compliance Framework Specification.

With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with
``fatal=False`` (external JSONs) it is skipped with a warning and ``None``
is returned.
"""
try:
compliance_framework = Compliance.parse_file(compliance_specification_file)
return Compliance.parse_file(compliance_specification_file)
except ValidationError as error:
logger.critical(
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
if fatal:
logger.critical(
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
)
sys.exit(1)
logger.warning(
f"Skipping invalid compliance framework {compliance_specification_file}: {error}"
)
sys.exit(1)
else:
return compliance_framework
return None


# ─── Universal Compliance Schema Models (Phase 1-3) ─────────────────────────
Expand Down Expand Up @@ -982,6 +996,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict:
if compliance_root and os.path.isdir(compliance_root):
_load_jsons_from_dir(compliance_root, provider, bulk)

# External multi-provider frameworks via the dedicated universal entry
# point group, kept separate from the per-provider `prowler.compliance`
# group so the legacy loader never parses a universal JSON. Built-ins
# (already in bulk) win on a name collision.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
try:
module = ep.load()
ep_dir = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
if os.path.isdir(ep_dir):
_load_jsons_from_dir(ep_dir, provider, bulk)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)

except Exception as e:
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
return bulk
121 changes: 121 additions & 0 deletions tests/lib/check/universal_compliance_models_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import json
import os
import tempfile
from unittest.mock import MagicMock, patch

import pytest
from pydantic.v1 import ValidationError
Expand All @@ -23,6 +25,7 @@
TableLabels,
UniversalComplianceRequirement,
adapt_legacy_to_universal,
get_bulk_compliance_frameworks_universal,
load_compliance_framework_universal,
)
from tests.lib.outputs.compliance.fixtures import (
Expand Down Expand Up @@ -1116,3 +1119,121 @@ def test_multiple_errors_reported(self):
],
attributes_metadata=self._metadata(enum=["high", "low"]),
)


class TestGetBulkUniversalEntryPoints:
"""Entry-point discovery for universal (multi-provider) compliance frameworks."""

@staticmethod
def _write_universal_json(directory, filename, framework, display_name):
data = {
"framework": framework,
"name": display_name,
"version": "1.0",
"description": "External multi-provider framework",
"requirements": [
{
"id": "1",
"name": "Requirement 1",
"description": "desc",
"checks": {"fakeexternal": ["check_a"]},
}
],
}
with open(os.path.join(directory, filename), "w") as f:
json.dump(data, f)

@staticmethod
def _entry_point(path):
module = MagicMock()
module.__path__ = [path]
ep = MagicMock()
ep.name = "fakeexternal"
ep.group = "prowler.compliance.universal"
ep.load.return_value = module
return ep

@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_includes_external_universal_framework(self, mock_list_modules, mock_ep):
mock_list_modules.return_value = []
with tempfile.TemporaryDirectory() as ep_dir:
self._write_universal_json(
ep_dir, "customuniversal_1.0.json", "CustomUniversal", "Custom"
)
mock_ep.return_value = [self._entry_point(ep_dir)]

bulk = get_bulk_compliance_frameworks_universal("fakeexternal")

mock_ep.assert_called_with(group="prowler.compliance.universal")
assert "customuniversal_1.0" in bulk
assert bulk["customuniversal_1.0"].framework == "CustomUniversal"

@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_builtin_wins_over_external_on_name_collision(
self, mock_list_modules, mock_ep
):
with (
tempfile.TemporaryDirectory() as root,
tempfile.TemporaryDirectory() as ep_dir,
):
builtin_sub = os.path.join(root, "builtinprov")
os.makedirs(builtin_sub)
self._write_universal_json(
builtin_sub, "shared_1.0.json", "SharedFramework", "Built-in"
)
builtin_module = MagicMock()
builtin_module.module_finder.path = root
builtin_module.name = "prowler.compliance.builtinprov"
mock_list_modules.return_value = [builtin_module]

self._write_universal_json(
ep_dir, "shared_1.0.json", "SharedFramework", "External"
)
mock_ep.return_value = [self._entry_point(ep_dir)]

bulk = get_bulk_compliance_frameworks_universal("fakeexternal")

assert "shared_1.0" in bulk
assert bulk["shared_1.0"].name == "Built-in"

@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_loads_all_frameworks_in_a_single_entry_point_path(
self, mock_list_modules, mock_ep
):
"""All JSONs in one entry-point directory are added, not collapsed to one."""
mock_list_modules.return_value = []
with tempfile.TemporaryDirectory() as ep_dir:
self._write_universal_json(ep_dir, "fw_a_1.0.json", "FwA", "Framework A")
self._write_universal_json(ep_dir, "fw_b_1.0.json", "FwB", "Framework B")
mock_ep.return_value = [self._entry_point(ep_dir)]

bulk = get_bulk_compliance_frameworks_universal("fakeexternal")

assert "fw_a_1.0" in bulk
assert "fw_b_1.0" in bulk

@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_merges_frameworks_from_multiple_packages_same_provider(
self, mock_list_modules, mock_ep
):
"""Two packages under the same provider name are both discovered."""
mock_list_modules.return_value = []
with (
tempfile.TemporaryDirectory() as dir_a,
tempfile.TemporaryDirectory() as dir_b,
):
self._write_universal_json(dir_a, "pkg_a_1.0.json", "PkgA", "Package A")
self._write_universal_json(dir_b, "pkg_b_1.0.json", "PkgB", "Package B")
mock_ep.return_value = [
self._entry_point(dir_a),
self._entry_point(dir_b),
]

bulk = get_bulk_compliance_frameworks_universal("fakeexternal")

assert "pkg_a_1.0" in bulk
assert "pkg_b_1.0" in bulk
85 changes: 85 additions & 0 deletions tests/providers/external/test_dynamic_provider_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,48 @@ def test_get_available_compliance_includes_external(self, mock_dirs):

assert "custom_1.0_ext" in frameworks

@patch("prowler.config.config.importlib.metadata.entry_points")
def test_get_available_compliance_includes_external_universal(self, mock_ep):
"""External universal frameworks under prowler.compliance.universal are
listed, for a provider and for the provider=None case that feeds
--compliance choices."""
import json
import os
import tempfile

from prowler.config.config import get_available_compliance_frameworks

with tempfile.TemporaryDirectory() as tmpdir:
framework = {
"framework": "CustomUniversal",
"name": "Custom Universal",
"version": "1.0",
"description": "Multi-provider",
"requirements": [
{
"id": "1",
"name": "r",
"description": "d",
"checks": {"aws": ["c"]},
}
],
}
with open(os.path.join(tmpdir, "customuniversal_1.0.json"), "w") as f:
json.dump(framework, f)

module = MagicMock()
module.__path__ = [tmpdir]
ep = _make_entry_point(
"anyname", "pkg.compliance", "prowler.compliance.universal"
)
ep.load.return_value = module
mock_ep.side_effect = lambda group: (
[ep] if group == "prowler.compliance.universal" else []
)

assert "customuniversal_1.0" in get_available_compliance_frameworks("aws")
assert "customuniversal_1.0" in get_available_compliance_frameworks(None)

@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep):
Expand Down Expand Up @@ -1257,6 +1299,49 @@ def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep):
assert "custom_1.0_fakeexternal" in bulk
assert bulk["custom_1.0_fakeexternal"].Framework == "Custom"

@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_compliance_get_bulk_skips_non_legacy_external_json(
self, mock_list_modules, mock_ep
):
"""A universal-schema JSON registered under prowler.compliance is skipped,
not aborting the run via sys.exit."""
import json
import os
import tempfile

from prowler.lib.check.compliance_models import Compliance

mock_list_modules.return_value = []

with tempfile.TemporaryDirectory() as tmpdir:
json_data = {
"framework": "Universal",
"name": "Universal Framework",
"version": "1.0",
"description": "Multi-provider",
"requirements": [
{
"id": "1",
"name": "r",
"description": "d",
"checks": {"aws": ["c"]},
}
],
}
with open(os.path.join(tmpdir, "universal_1.0.json"), "w") as f:
json.dump(json_data, f)

mock_module = MagicMock()
mock_module.__path__ = [tmpdir]
ep = _make_entry_point("aws", "pkg.compliance", "prowler.compliance")
ep.load.return_value = mock_module
mock_ep.return_value = [ep]

bulk = Compliance.get_bulk("aws")

assert "universal_1.0" not in bulk

@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_compliance_get_bulk_file_fallback(self, mock_list_modules, mock_ep):
Expand Down
Loading