Kanji
・Cloud engineer / freelance ・Born in 1993 ・From Ehime / Living in Shibuya, Tokyo ・5 years AWS experience Profile details
Table of Contents
Provider.get_global_provider()
resource
Check_Report_AWS(metadata=self.metadata(), resource=...)
pydantic.v1
ModuleNotFoundError
"Check was not found for the AWS provider"
s3://
--checks-folder
AWSService
When automating security audits across a multi-account AWS Organization, you inevitably run into a gap: how do you continuously audit organization-specific security guardrails that do not exist in AWS Security Hub, or CIS Benchmark controls that Security Hub marks as “Manual”?
At first, I considered building AWS Config Custom Rules backed by AWS Lambda. However, maintaining Lambda functions and paying per-configuration-item evaluation costs across dozens of AWS accounts and regions quickly became expensive and operationally heavy. Instead, I adopted the custom checks feature ( --checks-folder / -x ) of the open-source cloud security tool Prowler . By running Prowler once a day on AWS CodeBuild or GitHub Actions, pulling our custom checks from a central S3 bucket, and pushing findings into AWS Security Hub (ASFF), we run custom organization-wide compliance checks for just a few dollars a month.
-x
However, the official documentation ( Custom Checks - Prowler Documentation ) only shows a minimal folder layout. Furthermore, major architectural breaking changes introduced in Prowler v4 and v5 ( Check_Report_AWS constructor arguments and Provider initialization) cause older Prowler v3 custom check examples to fail immediately.
Check_Report_AWS
Provider
In this guide—based on analyzing Prowler’s source code ( prowler/lib/check/ and prowler/providers/aws/ ) and testing in real environments—I will walk through the two implementation patterns for Prowler v4 / v5 (along with v3 migration differences) and how to debug the most common traps.
prowler/lib/check/
prowler/providers/aws/
▲ Prowler v4/v5 dynamic custom check loading (Local / S3) and dual client execution architecture
If you previously wrote custom checks for Prowler v3 or copied snippets from older blog posts, the Provider architecture overhaul in Prowler v4.0+ is the first hurdle you will hit.
When I migrated my v3.12.1 custom checks to a Prowler v4 / v5 environment, three breaking changes caused immediate failures:
<= v3.x
v4.x / v5.x
Check_Report_AWS(self.metadata())
Check_Report_AWS(metadata=self.metadata(), resource=resource_obj)
TypeError: missing 1 required positional argument: 'resource'
Resource metadata <class 'str'> could not be converted to dict
from prowler.providers.aws.lib.audit_info.audit_info import current_audit_info
Config(current_audit_info)
from prowler.providers.common.provider import Provider
Config(Provider.get_global_provider())
audit_info
"Check was not found"
from pydantic import BaseModel
from pydantic.v1 import BaseModel
.dict()
pydantic.v1.BaseModel
The second item ( ModuleNotFoundError ) is especially deceptive: Prowler’s execute_checks() catches any ModuleNotFoundError raised inside your check script and logs "Check '<name>' was not found for the AWS provider" , making you think your folder path is wrong when in reality an internal import failed.
execute_checks()
"Check '<name>' was not found for the AWS provider"
import
Writing a custom check in Prowler follows one of two patterns depending on whether Prowler’s built-in service client already fetches the AWS API attributes you need :
prowler/providers/aws/services/{service}/{service}_service.py
{service}_client
When using a built-in client, create three files inside a subfolder named ${service_name}_${check_name} :
${service_name}_${check_name}
custom-checks-folder/ └── account_alternative_contact_information_is_registered/ ├── __init__.py ├── account_alternative_contact_information_is_registered.py └── account_alternative_contact_information_is_registered.metadata.json
You must follow two strict naming rules enforced by Prowler’s loader:
parse_checks_from_folder()
check.name.split("_")[0]
prowler/providers/aws/services/{service_name}/
account
prowler/providers/aws/services/
file_name == class_name == CheckID
Check.__init__()
class
"CheckID"
.metadata.json
ValidationError
awslambda
account_alternative_contact_information_is_registered.py
Suppose we want to automate CIS AWS Foundations Benchmark v3.0 control 1.2 (ensuring alternate contacts are registered). Surprisingly, Prowler’s built-in account_security_contact_information_is_registered check simply returns status = "MANUAL" . Yet if you read account_service.py , Prowler already calls account:GetAlternateContact and populates account_client.contact_emails with the SECURITY, BILLING, and OPERATIONS contact emails!
1.2
account_security_contact_information_is_registered
status = "MANUAL"
account_service.py
account:GetAlternateContact
account_client.contact_emails
Here is the Prowler v4 / v5 custom check that turns this into an automated PASS / FAIL check:
PASS
FAIL
import json from prowler.lib.logger import logger from prowler.lib.check.models import Check, Check_Report_AWS from prowler.providers.aws.services.account.account_client import account_client class account_alternative_contact_information_is_registered(Check): def execute(self): desired_alternative_contact_count = account_client.audit_config.get( "desired_alternative_contact_count", 3 ) findings = [] logger.debug(f"account_client.contact_names: {account_client.contact_names}") logger.debug(f"account_client.contact_emails: {account_client.contact_emails}") contact_emails = set(account_client.contact_emails or []) contact_emails.discard(None) # In Prowler v4/v5, the 2nd argument 'resource' (dict or BaseModel) is mandatory report = Check_Report_AWS(metadata=self.metadata(), resource={}) report.region = account_client.region report.resource_arn = account_client.audited_account_arn report.resource_id = account_client.audited_account if len(contact_emails) < desired_alternative_contact_count: report.status = "FAIL" else: report.status = "PASS" report.status_extended = json.dumps( { "desired_alternative_contact_count": desired_alternative_contact_count, "contact_emails_count": len(contact_emails), "contact_emails": sorted(list(contact_emails)), } ) findings.append(report) return findings
Difference from Prowler v3 : In Prowler v3, you could initialize Check_Report_AWS(self.metadata()) with a single argument. In Prowler v4 / v5, the resource argument is mandatory: Check_Report_AWS(metadata=self.metadata(), resource={}) . When iterating over resource models (such as an S3 bucket or EC2 instance), simply passing resource=bucket automatically populates resource_id , resource_arn , region , and resource_tags from the model.
Check_Report_AWS(metadata=self.metadata(), resource={})
bucket
resource=bucket
resource_id
resource_arn
region
resource_tags
account_alternative_contact_information_is_registered.metadata.json
{ "Provider": "aws", "CheckID": "account_alternative_contact_information_is_registered", "CheckTitle": "Ensure account alternative contact information (Security, Billing, Operations) is registered", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices" ], "ServiceName": "account", "SubServiceName": "", "ResourceIdTemplate": "arn:aws:account::account-id:account", "Severity": "medium", "ResourceType": "AwsAccount", "Description": "Ensure AWS account has alternative contacts registered for Security, Billing, and Operations.", "Risk": "Without valid alternative contacts, AWS security and billing notifications may not reach the responsible operations team in time.", "RelatedUrl": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-alternate.html", "Remediation": { "Code": { "CLI": "aws account put-alternate-contact --alternate-contact-type SECURITY --email-address sec@example.com --name SecurityTeam --phone-number +81-3-0000-0000 --title Security", "NativeIaC": "", "Other": "", "Terraform": "resource \"aws_account_alternate_contact\" \"security\" {\n alternate_contact_type = \"SECURITY\"\n name = \"Security Team\"\n title = \"Security\"\n email_address = \"sec@example.com\"\n phone_number = \"+81300000000\"\n}" }, "Recommendation": { "Text": "Register Security, Billing, and Operations alternate contacts via AWS Organizations or Account settings.", "Url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-alternate.html" } }, "Categories": [ "custom-checks" ], "DependsOn": [], "RelatedTo": [], "Notes": "" }
Prowler’s built-in config_service.py collects Configuration Recorders and Aggregators, but what if you need to inspect AWS Config Delivery Channels ( describe_delivery_channels ) to verify target S3 buckets and SNS topics across all regions?
config_service.py
describe_delivery_channels
Because parse_checks_from_folder() only copies individual check subfolders into prowler/providers/aws/services/{service}/ , any custom service client must be bundled inside the check subfolder itself:
prowler/providers/aws/services/{service}/
custom-checks-folder/ └── config_delivery_channel_enabled/ ├── __init__.py ├── config_delivery_channel_enabled.py ├── config_delivery_channel_enabled.metadata.json ├── cst_config_client.py └── cst_config_service.py
cst_config_service.py
cst_config_client.py
In Prowler v4 / v5, pass Provider.get_global_provider() to AWSService.__init__ , and use self.__threading_call__() to query all audited regions in parallel:
AWSService.__init__
self.__threading_call__()
# cst_config_service.py from typing import Optional from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService class CustomConfig(AWSService): def __init__(self, provider): super().__init__("config", provider) self.delivery_channels = [] self.__threading_call__(self._describe_delivery_channels) def _describe_delivery_channels(self, regional_client): logger.info("CustomConfig - Listing Delivery Channels...") try: channels = regional_client.describe_delivery_channels().get( "DeliveryChannels", [] ) if not channels: self.delivery_channels.append( DeliveryChannel( id=self.audited_account, arn=f"arn:{self.audited_partition}:config:{regional_client.region}:{self.audited_account}:delivery-channel/none", name=self.audited_account, s3_bucket_name=None, s3_key_prefix=None, sns_topic_arn=None, region=regional_client.region, ) ) return for channel in channels: channel_name = channel.get("name", "default") if not self.audit_resources or is_resource_filtered( channel_name, self.audit_resources ): self.delivery_channels.append( DeliveryChannel( id=channel_name, arn=f"arn:{self.audited_partition}:config:{regional_client.region}:{self.audited_account}:delivery-channel/{channel_name}", name=channel_name, s3_bucket_name=channel.get("s3BucketName"), s3_key_prefix=channel.get("s3KeyPrefix"), sns_topic_arn=channel.get("snsTopicARN"), region=regional_client.region, ) ) except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) class DeliveryChannel(BaseModel): id: str arn: str name: str s3_bucket_name: Optional[str] = None s3_key_prefix: Optional[str] = None sns_topic_arn: Optional[str] = None region: str
# cst_config_client.py from prowler.providers.common.provider import Provider from cst_config_service import CustomConfig custom_config_client = CustomConfig(Provider.get_global_provider())
config_delivery_channel_enabled.py
Append os.path.dirname(__file__) to sys.path before importing cst_config_client . Because DeliveryChannel includes id , arn , and region , passing resource=channel to Check_Report_AWS automatically populates the finding’s resource identifiers:
os.path.dirname(__file__)
sys.path
cst_config_client
DeliveryChannel
id
arn
resource=channel
# config_delivery_channel_enabled.py import os import sys from prowler.lib.check.models import Check, Check_Report_AWS sys.path.append(os.path.dirname(__file__)) from cst_config_client import custom_config_client # noqa: E402 class config_delivery_channel_enabled(Check): def execute(self): findings = [] for channel in custom_config_client.delivery_channels: report = Check_Report_AWS(metadata=self.metadata(), resource=channel) if channel.s3_bucket_name: report.status = "PASS" report.status_extended = ( f"AWS Config delivery channel '{channel.name}' is delivering to S3 bucket '{channel.s3_bucket_name}'." ) else: report.status = "FAIL" report.status_extended = ( f"No AWS Config delivery channel with an S3 bucket is configured in region {channel.region}." ) findings.append(report) return findings
During development, combine --checks-folder ( -x ) and --checks ( -c ) to run only your new check:
--checks
-c
# Run a single custom check locally with DEBUG logs prowler aws \ --checks-folder ./custom-checks-folder/ \ --checks account_alternative_contact_information_is_registered \ --log-level DEBUG \ -M csv json-ocsf \ --output-directory ./output > debug.log 2>&1 # Load custom checks directly from S3 (trailing slash '/' is required!) and send to Security Hub prowler aws \ --checks-folder s3://my-org-security-audit-checks/custom-checks-folder/ \ --checks account_alternative_contact_information_is_registered config_delivery_channel_enabled \ --security-hub
The most frustrating trap I hit when developing custom checks was seeing this error:
[ERROR] Check 'config_delivery_channel_enabled' was not found for the AWS provider
In prowler/lib/check/check.py , execute_checks() wraps execute() in except ModuleNotFoundError: . This means if your custom check fails to import a module internally (such as forgetting sys.path.append or importing the removed prowler.providers.aws.lib.audit_info module), Prowler catches that ModuleNotFoundError and prints "Check was not found" .
prowler/lib/check/check.py
execute()
except ModuleNotFoundError:
sys.path.append
prowler.providers.aws.lib.audit_info
Reading parse_checks_from_folder() in prowler/lib/check/check.py reveals three critical operational constraints:
/
^s3://([^/]+)/(.*?([^/]+))/$
FileNotFoundError
site-packages/prowler
{prowler_dir[0]}/providers/aws/services/{service}/{check_name}
remove_custom_checks_module()
shutil.rmtree()
{service}_service.py
__init__.py
.py
resource=...
current_audit_info
Because Prowler’s internal Provider and Pydantic metadata schemas continue to evolve across major releases, I recommend pinning the Prowler version in your production CodeBuild / container image and running a quick smoke test on your --checks-folder checks whenever you upgrade Prowler.
Expert architectural design for AWS Organizations, Control Tower, IAM Identity Center, Prowler custom compliance checks, and Terraform / Cloud Run CI/CD.
See our serverless cloud and AI engineering in action with Alpha Radar (AI market intelligence) and CandidLens (AI visual analytics).