Skip to content
Open
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
37 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
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
5 changes: 5 additions & 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)
- `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844)
- Universal compliance pipeline integrated into the CLI: `--list-compliance` and `--list-compliance-requirements` show universal frameworks, and CSV plus OCSF outputs are generated for any framework declaring a `TableConfig` [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808)
Expand Down Expand Up @@ -70,6 +71,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Google Workspace check reports now store the actual domain or account resource subject instead of `provider.identity` [(#10901)](https://github.com/prowler-cloud/prowler/pull/10901)
- `entra_users_mfa_capable` evaluating disabled guest accounts; CIS 5.2.3.4 only targets enabled member users [(#10785)](https://github.com/prowler-cloud/prowler/pull/10785)

### 🐞 Fixed

- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)

---

## [5.24.3] (Prowler v5.24.3)
Expand Down
41 changes: 34 additions & 7 deletions prowler/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from colorama import init as colorama_init

from prowler.config.config import (
EXTERNAL_TOOL_PROVIDERS,
cloud_api_base_url,
csv_file_suffix,
get_available_compliance_frameworks,
Expand Down Expand Up @@ -207,9 +206,10 @@ def prowler():
# We treat the compliance framework as another output format
if compliance_framework:
args.output_formats.extend(compliance_framework)
# If no input compliance framework, set all, unless a specific service or check is input
# Skip for IAC and LLM providers that don't use compliance frameworks
elif default_execution and provider not in ["iac", "llm"]:
# If no input compliance framework, set all, unless a specific service or check is input.
# Skip for tool-wrapper providers (iac, llm, image, and any external plug-in
# declaring `is_external_tool_provider = True`) — they don't use compliance frameworks.
elif default_execution and not Provider.is_tool_wrapper_provider(provider):
args.output_formats.extend(get_available_compliance_frameworks(provider))

# Set Logger configuration
Expand Down Expand Up @@ -247,7 +247,7 @@ def prowler():
universal_frameworks = {}

# Skip compliance frameworks for external-tool providers
if provider not in EXTERNAL_TOOL_PROVIDERS:
if not Provider.is_tool_wrapper_provider(provider):
Comment thread
HugoPBrito marked this conversation as resolved.
bulk_compliance_frameworks = Compliance.get_bulk(provider)
# Complete checks metadata with the compliance framework specification
bulk_checks_metadata = update_checks_metadata_with_compliance(
Expand Down Expand Up @@ -315,7 +315,7 @@ def prowler():
sys.exit()

# Skip service and check loading for external-tool providers
if provider not in EXTERNAL_TOOL_PROVIDERS:
if not Provider.is_tool_wrapper_provider(provider):
# Import custom checks from folder
if checks_folder:
custom_checks = parse_checks_from_folder(global_provider, checks_folder)
Expand Down Expand Up @@ -426,6 +426,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)

# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
Expand All @@ -435,7 +438,7 @@ def prowler():
# Execute checks
findings = []

if provider in EXTERNAL_TOOL_PROVIDERS:
if Provider.is_tool_wrapper_provider(provider):
# For external-tool providers, run the scan directly
if provider == "llm":

Expand Down Expand Up @@ -1343,6 +1346,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:
# 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
75 changes: 61 additions & 14 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 @@ -82,13 +83,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 current_provider in providers:
compliance_dir = f"{actual_directory}/../compliance/{current_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 @@ -97,7 +123,8 @@ def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks.append(
file.name.removesuffix(".json")
)
# Also scan top-level compliance/ for multi-provider (universal) JSONs.
# Built-in multi-provider frameworks at top-level compliance/ directory.
# Placed before external entry points so built-ins win on name collisions.
# When a specific provider was requested, only include the framework if it
# declares support for that provider; otherwise include all universal frameworks.
compliance_root = f"{actual_directory}/../compliance"
Expand All @@ -114,6 +141,18 @@ 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.
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 Expand Up @@ -225,18 +264,26 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict:
with open(config_file_path, "r", encoding=encoding_format_utf_8) as f:
config_file = yaml.safe_load(f)

# Not to introduce a breaking change, allow the old format config file without any provider keys
# and a new format with a key for each provider to include their configuration values within.
if any(
key in config_file
for key in ["aws", "gcp", "azure", "kubernetes", "m365"]
# Namespaced format: each provider has its own top-level key.
# Works for every built-in and every external plugin without a hardcoded list.
# Flat legacy format is AWS-only (historical, pre-multicloud). We identify it
# by the absence of nested-dict top-level values (namespaced files always
# have dict values; the legacy AWS format only has primitives/lists).
if (
isinstance(config_file, dict)
and provider in config_file
and isinstance(config_file[provider], dict)
):
config = config_file.get(provider, {}) or {}
elif (
isinstance(config_file, dict)
and config_file
and provider == "aws"
and not any(isinstance(v, dict) for v in config_file.values())
):
config = config_file.get(provider, {})
config = config_file
else:
config = config_file if config_file else {}
# Not to break Azure, K8s and GCP does not support or use the old config format
if provider in ["azure", "gcp", "kubernetes", "m365"]:
config = {}
config = {}

return config

Expand Down
59 changes: 53 additions & 6 deletions prowler/lib/check/check.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import importlib
import importlib.metadata
import importlib.util
import json
import os
import re
Expand All @@ -19,6 +21,7 @@
from prowler.lib.logger import logger
from prowler.lib.outputs.outputs import report
from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes
from prowler.providers.common.builtin import is_builtin_provider
from prowler.providers.common.models import Audit_Metadata


Expand Down Expand Up @@ -385,6 +388,45 @@ 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.

Built-in wins on CheckID collision. Plug-ins are first-class extenders
(they can add new checks under new CheckIDs) but cannot override
existing built-ins — a security tool prefers fail-loud predictability
over silent overrides. CheckMetadata.get_bulk() applies the same
precedence on the metadata side (first-write-wins) and emits a warning
when a plug-in tries to override, so the user knows their plug-in
duplicate is being ignored and can rename it.

Gates the built-in branch on `is_builtin_provider(provider_type)` —
calling `find_spec` on `prowler.providers.{provider_type}.services...`
directly would propagate `ModuleNotFoundError` for external providers
(their parent package `prowler.providers.{provider_type}` does not
exist) instead of returning None. The leaf helper encapsulates the
safe lookup, so external providers go straight to entry points. For
built-ins we still use `find_spec` to distinguish "check doesn't
exist" from "check exists but failed to import" (broken transitive
dep, etc.).
"""
# Built-in first — built-in wins on CheckID collision
if is_builtin_provider(provider_type):
builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}"
if importlib.util.find_spec(builtin_path) is not None:
return import_check(builtin_path)

# Entry point lookup — only consulted when the built-in truly doesn't exist
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 @@ -525,9 +567,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 @@ -605,9 +648,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 @@ -745,6 +789,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
11 changes: 8 additions & 3 deletions prowler/lib/check/checks_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

from colorama import Fore, Style

from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.check.check import parse_checks_from_file
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.models import CheckMetadata, Severity
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
from prowler.lib.logger import logger


Expand All @@ -26,8 +26,13 @@ def load_checks_to_execute(
) -> set:
"""Generate the list of checks to execute based on the cloud provider and the input arguments given"""
try:
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
# Bypass check loading for tool-wrapper providers — they delegate
# scanning to an external tool and have no checks to recover.
# Single source of truth across __main__, the CheckMetadata validators,
# check discovery and this loader, covering both built-in tool wrappers
# (iac/llm/image) and external plug-ins that declare
# `is_external_tool_provider = True` via the contract.
if is_tool_wrapper_provider(provider):
return set()

# Local subsets
Expand Down
Loading
Loading