Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
58 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
fb22107
Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-dis…
pedrooot Jun 8, 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
26 changes: 26 additions & 0 deletions .github/workflows/sdk-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,32 @@ jobs:
flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml

# External Provider (dynamic loading)
- name: Check if External Provider files changed
if: steps.check-changes.outputs.any_changed == 'true'
id: changed-external
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with:
files: |
./prowler/providers/common/**
./prowler/config/**
./prowler/lib/**
./tests/providers/external/**
./poetry.lock

- name: Run External Provider tests
if: steps.changed-external.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/common --cov=./prowler/config --cov=./prowler/lib --cov-report=xml:external_coverage.xml tests/providers/external

- name: Upload External Provider coverage to Codecov
if: steps.changed-external.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: prowler-py${{ matrix.python-version }}-external
files: ./external_coverage.xml

# Lib
- name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true'
Expand Down
1 change: 1 addition & 0 deletions prowler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.

### 🚀 Added

- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- SARIF output format for the IaC provider, enabling GitHub Code Scanning integration via `--output-formats sarif` [(#10626)](https://github.com/prowler-cloud/prowler/pull/10626)

---
Expand Down
27 changes: 27 additions & 0 deletions prowler/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,9 @@ def prowler():
output_options = VercelOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
else:
# Dynamic fallback: any external/custom provider
output_options = global_provider.get_output_options(args, bulk_checks_metadata)
Comment thread
danibarranqueroo marked this conversation as resolved.
Outdated

# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
Expand Down Expand Up @@ -1295,6 +1298,30 @@ def streaming_callback(findings_batch):
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
else:
# Dynamic fallback: any external/custom provider
try:
global_provider.generate_compliance_output(
finding_outputs,
bulk_compliance_frameworks,
input_compliance_frameworks,
output_options,
generated_outputs,
)
except NotImplementedError:
Comment thread
danibarranqueroo marked this conversation as resolved.
# Last resort: generic compliance
for compliance_name in input_compliance_frameworks:
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
generic_compliance = GenericCompliance(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()

# AWS Security Hub Integration
if provider == "aws":
Expand Down
43 changes: 40 additions & 3 deletions prowler/config/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib.metadata
import os
import pathlib
from datetime import datetime, timezone
Expand Down Expand Up @@ -76,13 +77,38 @@ class Provider(str, Enum):
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}."""
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]
elif hasattr(module, "__file__"):
dirs[ep.name] = os.path.dirname(module.__file__)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return dirs


def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks = []
providers = [p.value for p in Provider]
# Built-in compliance
compliance_base = f"{actual_directory}/../compliance"
if provider:
providers = [provider]
for provider in providers:
compliance_dir = f"{actual_directory}/../compliance/{provider}"
else:
# Scan compliance directory for all provider subdirectories
providers = []
if os.path.isdir(compliance_base):
for entry in os.scandir(compliance_base):
if entry.is_dir():
providers.append(entry.name)
for prov in providers:
compliance_dir = f"{compliance_base}/{prov}"
if not os.path.isdir(compliance_dir):
continue
with os.scandir(compliance_dir) as files:
Expand All @@ -91,6 +117,17 @@ def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks.append(
file.name.removesuffix(".json")
)
# External compliance via entry points
ep_dirs = _get_ep_compliance_dirs()
for prov, path in ep_dirs.items():
if provider and prov != provider:
continue
if os.path.isdir(path):
for file in os.scandir(path):
if file.is_file() and file.name.endswith(".json"):
available_compliance_frameworks.append(
file.name.removesuffix(".json")
Comment thread
HugoPBrito marked this conversation as resolved.
Outdated
)
return available_compliance_frameworks


Expand Down
40 changes: 34 additions & 6 deletions prowler/lib/check/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,29 @@ def import_check(check_path: str) -> ModuleType:
return lib


def _resolve_check_module(
provider_type: str, service: str, check_name: str
) -> ModuleType:
"""Resolve and import a check module — tries built-in path first, then entry points."""
# Built-in path
builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}"
try:
return import_check(builtin_path)
Comment thread
HugoPBrito marked this conversation as resolved.
Outdated
except ModuleNotFoundError:
Comment thread
HugoPBrito marked this conversation as resolved.
Outdated
pass

# Entry point lookup
import importlib.metadata

for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider_type}"):
if ep.name == check_name:
return importlib.import_module(ep.value)

raise ModuleNotFoundError(
f"Check '{check_name}' not found for provider '{provider_type}'"
)


def run_fixer(check_findings: list) -> int:
"""
Run the fixer for the check if it exists and there are any FAIL findings
Expand Down Expand Up @@ -502,9 +525,10 @@ def execute_checks(
service = check_name.split("_")[0]
try:
try:
# Import check module
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Import check module (built-in or entry point)
lib = _resolve_check_module(
global_provider.type, service, check_name
)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
Expand Down Expand Up @@ -582,9 +606,10 @@ def execute_checks(
)
try:
try:
# Import check module
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Import check module (built-in or entry point)
lib = _resolve_check_module(
global_provider.type, service, check_name
)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
Expand Down Expand Up @@ -722,6 +747,9 @@ def execute(
is_finding_muted_args["tenancy_id"] = (
global_provider.identity.tenancy_id
)
else:
# External/custom provider — delegate identity args
is_finding_muted_args = global_provider.get_mutelist_finding_args()
for finding in check_findings:
if global_provider.type == "cloudflare":
is_finding_muted_args["account_id"] = finding.account_id
Expand Down
40 changes: 35 additions & 5 deletions prowler/lib/check/compliance_models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib.metadata
import json
import os
import sys
Expand Down Expand Up @@ -391,26 +392,55 @@ def get_bulk(provider: str) -> dict:
"""Bulk load all compliance frameworks specification into a dict"""
try:
bulk_compliance_frameworks = {}
# Built-in compliance from prowler/compliance/{provider}/
available_compliance_framework_modules = list_compliance_modules()
for compliance_framework in available_compliance_framework_modules:
if provider in compliance_framework.name:
compliance_specification_dir_path = (
f"{compliance_framework.module_finder.path}/{provider}"
)
# for compliance_framework in available_compliance_framework_modules:
for filename in os.listdir(compliance_specification_dir_path):
file_path = os.path.join(
compliance_specification_dir_path, filename
)
# Check if it is a file and ti size is greater than 0
if os.path.isfile(file_path) and os.stat(file_path).st_size > 0:
# Open Compliance file in JSON
# cis_v1.4_aws.json --> cis_v1.4_aws
compliance_framework_name = filename.split(".json")[0]
# Store the compliance info
bulk_compliance_frameworks[compliance_framework_name] = (
load_compliance_framework(file_path)
)

# External compliance via entry points
for ep in importlib.metadata.entry_points(group="prowler.compliance"):
if ep.name == provider:
try:
module = ep.load()
compliance_dir = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
for filename in os.listdir(compliance_dir):
if filename.endswith(".json"):
file_path = os.path.join(compliance_dir, filename)
if (
os.path.isfile(file_path)
and os.stat(file_path).st_size > 0
):
compliance_framework_name = filename.split(".json")[
0
]
if (
compliance_framework_name
not in bulk_compliance_frameworks
):
bulk_compliance_frameworks[
compliance_framework_name
] = load_compliance_framework(file_path)
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}")

Expand Down
7 changes: 4 additions & 3 deletions prowler/lib/check/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
from pydantic.v1 import BaseModel, Field, ValidationError, validator
from pydantic.v1.error_wrappers import ErrorWrapper

from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.utils import recover_checks_from_provider
from prowler.lib.logger import logger
from prowler.providers.common.provider import Provider as ProviderABC

# Valid ResourceGroup values as defined in the RFC
VALID_RESOURCE_GROUPS = frozenset(
Expand Down Expand Up @@ -466,7 +467,7 @@ def list(
# If the bulk checks metadata is not provided, get it
if not bulk_checks_metadata:
bulk_checks_metadata = {}
available_providers = [p.value for p in Provider]
available_providers = ProviderABC.get_available_providers()
for provider_name in available_providers:
bulk_checks_metadata.update(CheckMetadata.get_bulk(provider_name))
if provider:
Expand All @@ -491,7 +492,7 @@ def list(
# Loaded here, as it is not always needed
if not bulk_compliance_frameworks:
bulk_compliance_frameworks = {}
available_providers = [p.value for p in Provider]
available_providers = ProviderABC.get_available_providers()
for provider in available_providers:
bulk_compliance_frameworks = Compliance.get_bulk(provider=provider)
checks_from_compliance_framework = (
Expand Down
68 changes: 50 additions & 18 deletions prowler/lib/check/utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import importlib
import importlib.metadata
import os
import sys
from pkgutil import walk_packages

from prowler.lib.logger import logger


def _recover_ep_checks(provider: str) -> list[tuple]:
"""Discover external checks registered via entry points for a provider.

Uses find_spec to locate the check module without importing it,
avoiding service client initialization at discovery time.
"""
checks = []
for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider}"):
try:
spec = importlib.util.find_spec(ep.value)
if spec and spec.origin:
check_path = os.path.dirname(spec.origin)
checks.append((ep.name, check_path))
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return checks


def recover_checks_from_provider(
provider: str, service: str = None, include_fixers: bool = False
) -> list[tuple]:
Expand All @@ -19,24 +41,34 @@ def recover_checks_from_provider(
return []

checks = []
Comment thread
HugoPBrito marked this conversation as resolved.
modules = list_modules(provider, service)
for module_name in modules:
# Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
check_module_name = module_name.name
# We need to exclude common shared libraries in services
if (
check_module_name.count(".") == 6
and ".lib." not in check_module_name
and (not check_module_name.endswith("_fixer") or include_fixers)
):
check_path = module_name.module_finder.path
# Check name is the last part of the check_module_name
check_name = check_module_name.split(".")[-1]
check_info = (check_name, check_path)
checks.append(check_info)
except ModuleNotFoundError:
logger.critical(f"Service {service} was not found for the {provider} provider.")
sys.exit(1)
# Built-in checks from prowler.providers.{provider}.services
try:
modules = list_modules(provider, service)
for module_name in modules:
# Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
check_module_name = module_name.name
# We need to exclude common shared libraries in services
if (
check_module_name.count(".") == 6
and ".lib." not in check_module_name
and (not check_module_name.endswith("_fixer") or include_fixers)
):
check_path = module_name.module_finder.path
check_name = check_module_name.split(".")[-1]
check_info = (check_name, check_path)
checks.append(check_info)
except ModuleNotFoundError:
Comment thread
HugoPBrito marked this conversation as resolved.
Outdated
Comment thread
HugoPBrito marked this conversation as resolved.
Outdated
if service:
logger.critical(
f"Service {service} was not found for the {provider} provider."
)
sys.exit(1)
# No built-in services for this provider (e.g., external provider)

# External checks registered via entry points
if not service:
checks.extend(_recover_ep_checks(provider))

except Exception as e:
logger.critical(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}")
sys.exit(1)
Expand Down
Loading
Loading