Compare commits

..

1 Commits

Author SHA1 Message Date
pedrooot 70db6f3d74 fix(deps): bump dulwich to 1.2.6 to fix GHSA-897w-fcg9-f6xj 2026-06-02 11:00:39 +02:00
336 changed files with 40128 additions and 23031 deletions
+1 -28
View File
@@ -540,7 +540,7 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml
# Scaleway Provider
- name: Check if Scaleway files changed
if: steps.check-changes.outputs.any_changed == 'true'
@@ -588,34 +588,7 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-stackit
files: ./stackit_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/**
./uv.lock
- name: Run External Provider tests
if: steps.changed-external.outputs.any_changed == 'true'
run: uv 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'
-39
View File
@@ -2,45 +2,6 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.31.0] (Prowler UNRELEASED)
### 🚀 Added
- Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Label Postgres connections with `application_name="<component>:<alias>"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494)
### 🔄 Changed
- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
### 🐞 Fixed
- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
### 🔐 Security
- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner in `api/uv.lock` [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
---
## [1.30.3] (Prowler v5.29.3)
### 🐞 Fixed
- API startup no longer crashes when Neo4j is unreachable, as the Neo4j driver now connects lazily on first use rather than during app initialization [(#11491)](https://github.com/prowler-cloud/prowler/pull/11491)
---
## [1.30.1] (Prowler v5.29.1)
### 🐞 Fixed
- `GET /api/v1/findings` N+1 query loading `resources__tags` when listing findings [(#11420)](https://github.com/prowler-cloud/prowler/pull/11420)
- Clean up the scan tmp output directory when `scan-report` fails so partial files do not accumulate and fill the worker disk (`No space left on device`) [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
---
## [1.30.0] (Prowler v5.29.0)
### 🔄 Changed
+4 -13
View File
@@ -22,12 +22,12 @@ apply_fixtures() {
start_dev_server() {
echo "Starting the development server..."
exec uv run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}"
uv run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}"
}
start_prod_server() {
echo "Starting the Gunicorn server..."
exec uv run gunicorn -c config/guniconf.py config.wsgi:application
uv run gunicorn -c config/guniconf.py config.wsgi:application
}
resolve_worker_hostname() {
@@ -47,7 +47,7 @@ resolve_worker_hostname() {
start_worker() {
echo "Starting the worker..."
exec uv run python -m celery -A config.celery worker \
uv run python -m celery -A config.celery worker \
-n "$(resolve_worker_hostname)" \
-l "${DJANGO_LOGGING_LEVEL:-info}" \
-Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \
@@ -56,7 +56,7 @@ start_worker() {
start_worker_beat() {
echo "Starting the worker-beat..."
exec uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler
uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler
}
manage_db_partitions() {
@@ -68,15 +68,6 @@ manage_db_partitions() {
fi
}
# Identify this process to Postgres (application_name=<component>:<alias>) so
# connections are attributable by component in pg_stat_activity. Web tiers
# report "api"; everything else uses the launch subcommand.
case "$1" in
prod|dev) DJANGO_APP_COMPONENT="api" ;;
*) DJANGO_APP_COMPONENT="$1" ;;
esac
export DJANGO_APP_COMPONENT
case "$1" in
dev)
apply_migrations
-105
View File
@@ -1,105 +0,0 @@
# Orphan Celery task recovery
When a worker is terminated mid-task (a deploy, an OOM kill, a node eviction), the
task it was running can be left non-terminal forever: the `TaskResult` stays
`STARTED` and nothing re-runs it. This page describes the mechanisms that detect and
recover allowlisted idempotent orphans so pending-task alerts do not fire. Scan tasks
are not auto-recovered (re-running a scan is not safe to do automatically); the
watchdog covers the summary/aggregation and deletion tasks.
## How recovery works
1. **Durable delivery.** The broker is configured so a task message is acknowledged
only after the task finishes (`task_acks_late`), one task is reserved at a time
(`worker_prefetch_multiplier = 1`), and an abruptly-lost worker re-queues its task
(`task_reject_on_worker_lost`). On `SIGTERM` the worker is given a soft-shutdown
window (`worker_soft_shutdown_timeout`) to finish or re-queue in-flight work
before it is force-killed. `scan-perform`, `scan-perform-scheduled` and
`integration-jira` opt out of redelivery with `acks_late=False`, so a crash drops
them rather than re-running and duplicating findings or Jira issues. Other
non-recovered side-effect tasks keep `acks_late=True`, so the broker can still
re-deliver them after a worker loss: the S3 upload rebuilds from worker-local files
that did not survive the crash and so no-ops, but Security Hub re-reads findings from
the DB and re-sends them to AWS.
2. **Periodic watchdog.** A Beat task, `reconcile-orphan-tasks`, runs every couple of
minutes (a `django_celery_beat` periodic task created by migration). For each
in-flight task result with an allowlisted idempotent task name, it pings the
worker recorded on the task's `TaskResult`:
- worker responds -> the task is still running, leave it alone;
- worker is gone (and the task started before a short grace window) -> it is a
real orphan: the stale task is revoked and marked terminal (clearing the
pending/started alert), and the task is re-enqueued from its stored name and
kwargs.
The re-run is safe because only tasks with proven idempotency are allowlisted: the
summary/aggregation tasks clear and re-write their own rows, and deletions are
idempotent. Scan tasks and external side effects are excluded: re-running a scan is
not safe to do automatically, Jira sends would create duplicate issues, the S3
upload rebuilds from worker-local files that do not survive a crash, and
report/Security Hub recovery is out of scope.
3. **Recovery cap.** A per-task Valkey counter limits how often the same task is
re-enqueued. After `--max-attempts` recoveries (default 3) the orphan is marked
terminal instead of re-enqueued, so a task that repeatedly kills its worker cannot
loop forever.
A Postgres advisory lock ensures that, even with multiple API/worker replicas, only
one reconciliation runs at a time; the others no-op.
## On-demand command
The same logic is available as a management command, useful right after a deploy or
for manual intervention:
```bash
python manage.py reconcile_orphan_tasks # recover now
python manage.py reconcile_orphan_tasks --dry-run # report orphans, change nothing
python manage.py reconcile_orphan_tasks --grace-minutes 5 --max-attempts 3
```
## Configuration
All settings have safe defaults; override via environment variables.
| Env var | Default | Purpose |
| --- | --- | --- |
| `DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER` | `1` | Tasks reserved per worker process. |
| `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` | `60` | Seconds the worker drains/re-queues on `SIGTERM` before force-kill. |
| `DJANGO_CELERY_TASK_TIME_LIMIT` | `21600` (6h) | Hard limit for most tasks; connection checks are capped at 120s. |
| `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. |
| `DJANGO_CELERY_LONG_TASK_TIME_LIMIT` | `172800` (48h) | Hard limit for scans and provider/tenant deletions, which can legitimately run for more than a day. |
| `DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT` | long hard - 600 | Soft limit for the long-running tasks above. |
| `DJANGO_TASK_RECOVERY_ENABLED` | `false` | Master switch for orphan-task recovery, disabled by default (opt-in); set to `true` to enable. When off, no orphan is detected, marked terminal, or re-enqueued (attack-paths stale cleanup still runs). |
| `DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED` | `true` | Auto re-enqueue orphaned scan summary/aggregation tasks. |
| `DJANGO_TASK_RECOVERY_DELETIONS_ENABLED` | `true` | Auto re-enqueue orphaned provider/tenant deletion tasks. |
Recovery is opt-in: with the master flag off (the default) the sweep does nothing.
Once enabled, the per-group flags default to on, so every group recovers unless you
turn one off; a task whose group flag is off is marked terminal instead of
re-enqueued.
Turning recovery off only disables this watchdog sweep; it does not change Celery's
broker-level redelivery (`task_acks_late`/`task_reject_on_worker_lost`), which still
re-delivers tasks that keep `acks_late=True` on worker loss, independently of this flag.
`task_acks_late` and `task_reject_on_worker_lost` are enabled in `config/celery.py`.
## Deployment requirement
Two conditions must both hold for the soft shutdown to actually drain work:
1. **The worker must receive `SIGTERM`.** The container entrypoint `exec`s the
Celery process so it runs as PID 1; otherwise `SIGTERM` from `docker stop`/ECS
hits the entrypoint shell, never reaches Celery, and the worker is hard-killed
(SIGKILL) at the grace deadline without draining. Custom entrypoints must
preserve the `exec`.
2. **The orchestrator must give the worker enough time** before force-killing it.
Set the stop grace period to exceed `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT`
plus a margin:
- **docker-compose:** `stop_grace_period` on the worker services (set to `120s`).
- **AWS ECS:** the worker container `stopTimeout` (configured in the deployment
repository).
If either condition is missing, long tasks are still recovered by the watchdog,
but they are cut mid-run on every deploy instead of draining.
+3 -13
View File
@@ -226,7 +226,7 @@ constraint-dependencies = [
"drf-simple-apikey==2.2.1",
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
"dulwich==1.2.5",
"dulwich==0.23.0",
"duo-client==5.5.0",
"durationpy==0.10",
"email-validator==2.2.0",
@@ -354,7 +354,7 @@ constraint-dependencies = [
"pydantic-core==2.41.5",
"pygithub==2.8.0",
"pygments==2.20.0",
"pyjwt==2.13.0",
"pyjwt==2.12.1",
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
@@ -443,17 +443,7 @@ constraint-dependencies = [
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
# SDK's hard pin; override it to the patched, kiota-aligned version.
#
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
# against the SDK's hard pins, so override them to the patched versions until the SDK
# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
# override replaces the whole requirement; bare pyjwt would drop it from the consumers
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
"microsoft-kiota-abstractions==1.9.9",
"dulwich==1.2.5",
"pyjwt[crypto]==2.13.0"
"microsoft-kiota-abstractions==1.9.9"
]
+34 -6
View File
@@ -1,14 +1,12 @@
import logging
import os
import sys
from pathlib import Path
from django.apps import AppConfig
from django.conf import settings
from config.custom_logging import BackendLogger
from config.env import env
from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(BackendLogger.API)
@@ -32,6 +30,7 @@ class ApiConfig(AppConfig):
def ready(self):
from api import schema_extensions # noqa: F401
from api import signals # noqa: F401
from api.attack_paths import database as graph_database
# Generate required cryptographic keys if not present, but only if:
# `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app
@@ -42,8 +41,37 @@ class ApiConfig(AppConfig):
):
self._ensure_crypto_keys()
# Neo4j driver is created lazily on first use (see api.attack_paths.database).
# App init never contacts Neo4j, so a Neo4j outage cannot block API startup.
# Commands that don't need Neo4j
SKIP_NEO4J_DJANGO_COMMANDS = [
"makemigrations",
"migrate",
"pgpartition",
"check",
"help",
"showmigrations",
"check_and_fix_socialaccount_sites_migration",
]
# Skip eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook)
if getattr(settings, "TESTING", False) or (
len(sys.argv) > 1
and (
(
"manage.py" in sys.argv[0]
and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS
)
or "celery" in sys.argv[0]
)
):
logger.info(
"Skipping eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)"
)
else:
graph_database.init_driver()
# Neo4j driver is initialized at API startup (see api.attack_paths.database)
# It remains lazy for Celery workers and selected Django commands
def _ensure_crypto_keys(self):
"""
+4 -18
View File
@@ -1,24 +1,22 @@
import atexit
import logging
import threading
from contextlib import contextmanager
from typing import Any, Iterator
from uuid import UUID
import neo4j
import neo4j.exceptions
from config.env import env
from django.conf import settings
from api.attack_paths.retryable_session import RetryableSession
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
PROVIDER_RESOURCE_LABEL,
get_provider_label,
)
from api.attack_paths.retryable_session import RetryableSession
# Without this Celery goes crazy with Neo4j logging
logging.getLogger("neo4j").setLevel(logging.ERROR)
logging.getLogger("neo4j").propagate = False
@@ -30,9 +28,6 @@ READ_QUERY_TIMEOUT_SECONDS = env.int(
"ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
)
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
# Shorter than CONN_ACQUISITION_TIMEOUT — the driver requires acquisition to be
# the longer of the two (it may include opening a new connection).
CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5)
CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15)
READ_EXCEPTION_CODES = [
"Neo.ClientError.Statement.AccessMode",
@@ -63,24 +58,15 @@ def init_driver() -> neo4j.Driver:
uri = get_uri()
config = settings.DATABASES["neo4j"]
driver = neo4j.GraphDatabase.driver(
_driver = neo4j.GraphDatabase.driver(
uri,
auth=(config["USER"], config["PASSWORD"]),
keep_alive=True,
max_connection_lifetime=7200,
connection_timeout=CONNECTION_TIMEOUT,
connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT,
max_connection_pool_size=50,
)
# Publish the singleton only after connectivity is verified so a
# failed probe does not leave an unverified driver behind. Close the
# driver on failure so a repeatedly-probed outage cannot leak pools.
try:
driver.verify_connectivity()
except Exception:
driver.close()
raise
_driver = driver
_driver.verify_connectivity()
# Register cleanup handler (only runs once since we're inside the _driver is None block)
atexit.register(close_driver)
+36 -47
View File
@@ -1,9 +1,7 @@
from collections.abc import Iterable, Mapping
from api.models import Provider
from prowler.lib.check.compliance_models import (
get_bulk_compliance_frameworks_universal,
)
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.models import CheckMetadata
AVAILABLE_COMPLIANCE_FRAMEWORKS = {}
@@ -96,22 +94,25 @@ PROWLER_CHECKS = LazyChecksMapping()
def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]:
"""List compliance framework identifiers available for `provider_type`.
"""List compliance frameworks the API can load for `provider_type`.
Includes both per-provider frameworks and universal top-level frameworks
(e.g. ``dora``, ``csa_ccm_4.0``).
The list is sourced from `Compliance.get_bulk` so that the names
returned here are guaranteed to be loadable by the bulk loader. This
prevents downstream key mismatches (e.g. CSV report generation iterating
framework names and looking them up in the bulk dict).
Args:
provider_type (Provider.ProviderChoices): The cloud provider type
(e.g., "aws", "azure", "gcp", "m365").
provider_type (Provider.ProviderChoices): The cloud provider type for which to retrieve
available compliance frameworks (e.g., "aws", "azure", "gcp", "m365").
Returns:
list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora").
list[str]: A list of framework identifiers (e.g., "cis_1.4_aws", "mitre_attack_azure") available
for the given provider.
"""
global AVAILABLE_COMPLIANCE_FRAMEWORKS
if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS:
AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = list(
get_bulk_compliance_frameworks_universal(provider_type).keys()
Compliance.get_bulk(provider_type).keys()
)
return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type]
@@ -138,14 +139,18 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) ->
"""
Retrieve the Prowler compliance data for a specified provider type.
This function fetches the compliance frameworks and their associated
requirements for the given cloud provider.
Args:
provider_type (Provider.ProviderChoices): The provider type
(e.g., 'aws', 'azure') for which to retrieve compliance data.
Returns:
dict: Mapping of framework name to `ComplianceFramework` for the provider.
dict: A dictionary mapping compliance framework names to their respective
Compliance objects for the specified provider.
"""
return get_bulk_compliance_frameworks_universal(provider_type)
return Compliance.get_bulk(provider_type)
def _load_provider_assets(provider_type: Provider.ProviderChoices) -> tuple[dict, dict]:
@@ -204,8 +209,8 @@ def load_prowler_checks(
for compliance_name, compliance_data in prowler_compliance.get(
provider_type, {}
).items():
for requirement in compliance_data.requirements:
for check in requirement.checks.get(provider_type, []):
for requirement in compliance_data.Requirements:
for check in requirement.Checks:
try:
checks[provider_type][check].add(compliance_name)
except KeyError:
@@ -285,40 +290,24 @@ def generate_compliance_overview_template(
requirements_status = {"passed": 0, "failed": 0, "manual": 0}
total_requirements = 0
for requirement in compliance_data.requirements:
for requirement in compliance_data.Requirements:
total_requirements += 1
provider_check_list = list(requirement.checks.get(provider_type, []))
total_checks = len(provider_check_list)
checks_dict = {check: None for check in provider_check_list}
total_checks = len(requirement.Checks)
checks_dict = {check: None for check in requirement.Checks}
req_status_val = "MANUAL" if total_checks == 0 else "PASS"
# MITRE attrs are wrapped under `_raw_attributes` by the
# universal adapter — unwrap so consumers see the flat list.
requirement_attributes = requirement.attributes
if (
isinstance(requirement_attributes, dict)
and "_raw_attributes" in requirement_attributes
):
attributes_payload = list(requirement_attributes["_raw_attributes"])
elif isinstance(requirement_attributes, dict):
attributes_payload = (
[dict(requirement_attributes)] if requirement_attributes else []
)
else:
attributes_payload = [
dict(attribute) for attribute in requirement_attributes
]
# Build requirement dictionary
requirement_dict = {
"name": requirement.name or requirement.id,
"description": requirement.description,
"tactics": requirement.tactics or [],
"subtechniques": requirement.sub_techniques or [],
"platforms": requirement.platforms or [],
"technique_url": requirement.technique_url or "",
"attributes": attributes_payload,
"name": requirement.Name or requirement.Id,
"description": requirement.Description,
"tactics": getattr(requirement, "Tactics", []),
"subtechniques": getattr(requirement, "SubTechniques", []),
"platforms": getattr(requirement, "Platforms", []),
"technique_url": getattr(requirement, "TechniqueURL", ""),
"attributes": [
dict(attribute) for attribute in requirement.Attributes
],
"checks": checks_dict,
"checks_status": {
"pass": 0,
@@ -336,15 +325,15 @@ def generate_compliance_overview_template(
requirements_status["passed"] += 1
# Add requirement to compliance requirements
compliance_requirements[requirement.id] = requirement_dict
compliance_requirements[requirement.Id] = requirement_dict
# Build compliance dictionary
compliance_dict = {
"framework": compliance_data.framework,
"name": compliance_data.name,
"version": compliance_data.version,
"framework": compliance_data.Framework,
"name": compliance_data.Name,
"version": compliance_data.Version,
"provider": provider_type,
"description": compliance_data.description,
"description": compliance_data.Description,
"requirements": compliance_requirements,
"requirements_status": requirements_status,
"total_requirements": total_requirements,
@@ -1,59 +0,0 @@
from django.core.management.base import BaseCommand
from tasks.jobs.orphan_recovery import reconcile_orphans
class Command(BaseCommand):
help = (
"Recover orphaned allowlisted Celery tasks whose worker is gone and mark "
"other stale task results terminal. Single-flight via a Postgres advisory lock."
)
def add_arguments(self, parser):
parser.add_argument(
"--grace-minutes",
type=int,
default=2,
help="Skip tasks started within this window (worker may still register).",
)
parser.add_argument(
"--max-attempts",
type=int,
default=3,
help="Give up re-running a task after this many recovery attempts; it is then left terminal instead of re-enqueued.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Detect and report orphans without revoking or re-enqueuing.",
)
def handle(self, *args, **options):
result = reconcile_orphans(
grace_minutes=options["grace_minutes"],
max_attempts=options["max_attempts"],
dry_run=options["dry_run"],
)
if not result.get("acquired"):
self.stdout.write("Reconcile skipped: another run holds the lock.")
return
if result.get("enabled") is False:
message = (
"Task recovery is disabled (DJANGO_TASK_RECOVERY_ENABLED is off); "
"no orphans were recovered."
)
if result.get("attack_paths") is not None:
message += " Attack-paths stale cleanup still ran."
self.stdout.write(message)
return
self.stdout.write(
self.style.SUCCESS(
"Orphan reconcile complete: "
f"recovered={len(result.get('recovered', []))} "
f"failed={len(result.get('failed', []))} "
f"skipped(in-flight)={len(result.get('skipped', []))}"
)
)
@@ -1,49 +0,0 @@
from django.db import migrations
TASK_NAME = "reconcile-orphan-tasks"
INTERVAL_MINUTES = 2
def create_periodic_task(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
schedule, _ = IntervalSchedule.objects.get_or_create(
every=INTERVAL_MINUTES,
period="minutes",
)
PeriodicTask.objects.update_or_create(
name=TASK_NAME,
defaults={
"task": TASK_NAME,
"interval": schedule,
"enabled": True,
},
)
def delete_periodic_task(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
PeriodicTask.objects.filter(name=TASK_NAME).delete()
# Clean up the schedule if no other task references it
IntervalSchedule.objects.filter(
every=INTERVAL_MINUTES,
period="minutes",
periodictask__isnull=True,
).delete()
class Migration(migrations.Migration):
dependencies = [
("api", "0093_okta_provider"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.RunPython(create_periodic_task, delete_periodic_task),
]
+1 -52
View File
@@ -13137,59 +13137,8 @@ paths:
responses:
'200':
description: CSV file containing the compliance report
'202':
description: The task is in progress
'403':
description: There is a problem with credentials
'404':
description: Compliance report not found, or the scan has no reports yet
/api/v1/scans/{id}/compliance/{name}/ocsf:
get:
operationId: scans_compliance_ocsf_retrieve
description: Download a specific compliance report as an OCSF JSON file. Only
universal frameworks that declare an output configuration produce this artifact
(currently 'dora' and 'csa_ccm_4.0'); any other framework returns 404.
summary: Retrieve compliance report as OCSF JSON
parameters:
- in: query
name: fields[scan-reports]
schema:
type: array
items:
type: string
enum:
- id
- name
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this scan.
required: true
- in: path
name: name
schema:
type: string
description: The compliance report name, like 'dora'
required: true
tags:
- Scan
security:
- JWT or API Key: []
responses:
'200':
description: OCSF JSON file containing the compliance report
'202':
description: The task is in progress
'403':
description: There is a problem with credentials
'404':
description: Compliance report not found, the framework does not provide
an OCSF export, or the scan has no reports yet
description: Compliance report not found
/api/v1/scans/{id}/csa:
get:
operationId: scans_csa_retrieve
+44 -12
View File
@@ -182,19 +182,23 @@ def _make_app():
return ApiConfig("api", api)
@pytest.mark.parametrize(
"argv",
[
["gunicorn"],
["celery", "-A", "api"],
["manage.py", "migrate"],
],
ids=["api", "celery", "manage_py"],
)
def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv):
"""ready() must never contact Neo4j; the driver is created lazily on first use."""
def test_ready_initializes_driver_for_api_process(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, argv)
_set_argv(monkeypatch, ["gunicorn"])
_set_testing(monkeypatch, False)
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_called_once()
def test_ready_skips_driver_for_celery(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, ["celery", "-A", "api"])
_set_testing(monkeypatch, False)
with (
@@ -204,3 +208,31 @@ def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv):
config.ready()
init_driver.assert_not_called()
def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, ["manage.py", "migrate"])
_set_testing(monkeypatch, False)
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_not_called()
def test_ready_skips_driver_when_testing(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, ["gunicorn"])
_set_testing(monkeypatch, True)
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_not_called()
@@ -1,16 +1,15 @@
"""
Tests for Neo4j database lazy initialization.
The Neo4j driver is created on first use for every process type; app startup
never contacts Neo4j. These tests validate the database module behavior itself.
The Neo4j driver connects on first use by default. API processes may
eagerly initialize the driver during app startup, while Celery workers
remain lazy. These tests validate the database module behavior itself.
"""
import threading
from unittest.mock import MagicMock, patch
import neo4j
import neo4j.exceptions
import pytest
import api.attack_paths.database as db_module
@@ -60,32 +59,6 @@ class TestLazyInitialization:
assert result is mock_driver
assert db_module._driver is mock_driver
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_init_driver_leaves_driver_none_when_verify_fails(
self, mock_driver_factory, mock_settings
):
"""A failed verify_connectivity() must not publish or leak the driver."""
mock_driver = MagicMock()
mock_driver.verify_connectivity.side_effect = (
neo4j.exceptions.ServiceUnavailable("down")
)
mock_driver_factory.return_value = mock_driver
mock_settings.DATABASES = {
"neo4j": {
"HOST": "localhost",
"PORT": 7687,
"USER": "neo4j",
"PASSWORD": "password",
}
}
with pytest.raises(neo4j.exceptions.ServiceUnavailable):
db_module.init_driver()
assert db_module._driver is None
mock_driver.close.assert_called_once()
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_init_driver_returns_cached_driver_on_subsequent_calls(
@@ -143,23 +116,21 @@ class TestConnectionAcquisitionTimeout:
@pytest.fixture(autouse=True)
def reset_module_state(self):
original_driver = db_module._driver
original_acq_timeout = db_module.CONN_ACQUISITION_TIMEOUT
original_conn_timeout = db_module.CONNECTION_TIMEOUT
original_timeout = db_module.CONN_ACQUISITION_TIMEOUT
db_module._driver = None
yield
db_module._driver = original_driver
db_module.CONN_ACQUISITION_TIMEOUT = original_acq_timeout
db_module.CONNECTION_TIMEOUT = original_conn_timeout
db_module.CONN_ACQUISITION_TIMEOUT = original_timeout
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_driver_receives_configured_timeout(
self, mock_driver_factory, mock_settings
):
"""init_driver() should pass the configured timeouts to the neo4j driver."""
"""init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver."""
mock_driver_factory.return_value = MagicMock()
mock_settings.DATABASES = {
"neo4j": {
@@ -170,13 +141,11 @@ class TestConnectionAcquisitionTimeout:
}
}
db_module.CONN_ACQUISITION_TIMEOUT = 42
db_module.CONNECTION_TIMEOUT = 7
db_module.init_driver()
_, kwargs = mock_driver_factory.call_args
assert kwargs["connection_acquisition_timeout"] == 42
assert kwargs["connection_timeout"] == 7
class TestAtexitRegistration:
+40 -51
View File
@@ -12,9 +12,7 @@ from api.compliance import (
load_prowler_checks,
)
from api.models import Provider
from prowler.lib.check.compliance_models import (
get_bulk_compliance_frameworks_universal,
)
from prowler.lib.check.compliance_models import Compliance
class TestCompliance:
@@ -30,16 +28,16 @@ class TestCompliance:
assert set(checks) == {"check1", "check2", "check3"}
mock_check_metadata.get_bulk.assert_called_once_with(provider_type)
@patch("api.compliance.get_bulk_compliance_frameworks_universal")
def test_get_prowler_provider_compliance(self, mock_get_bulk):
@patch("api.compliance.Compliance")
def test_get_prowler_provider_compliance(self, mock_compliance):
provider_type = Provider.ProviderChoices.AWS
mock_get_bulk.return_value = {
mock_compliance.get_bulk.return_value = {
"compliance1": MagicMock(),
"compliance2": MagicMock(),
}
compliance_data = get_prowler_provider_compliance(provider_type)
assert compliance_data == mock_get_bulk.return_value
mock_get_bulk.assert_called_once_with(provider_type)
assert compliance_data == mock_compliance.get_bulk.return_value
mock_compliance.get_bulk.assert_called_once_with(provider_type)
@patch("api.compliance.get_prowler_provider_checks")
@patch("api.models.Provider.ProviderChoices")
@@ -53,9 +51,9 @@ class TestCompliance:
prowler_compliance = {
"aws": {
"compliance1": MagicMock(
requirements=[
Requirements=[
MagicMock(
checks={"aws": ["check1", "check2"]},
Checks=["check1", "check2"],
),
],
),
@@ -169,38 +167,35 @@ class TestCompliance:
def test_generate_compliance_overview_template(self, mock_provider_choices):
mock_provider_choices.values = ["aws"]
# ``name`` is a reserved MagicMock kwarg (it labels the mock for repr,
# it does NOT set a ``.name`` attribute), so it must be assigned
# explicitly after construction.
requirement1 = MagicMock(
id="requirement1",
description="Description of requirement 1",
attributes=[],
checks={"aws": ["check1", "check2"]},
tactics=["tactic1"],
sub_techniques=["subtechnique1"],
platforms=["platform1"],
technique_url="https://example.com",
Id="requirement1",
Name="Requirement 1",
Description="Description of requirement 1",
Attributes=[],
Checks=["check1", "check2"],
Tactics=["tactic1"],
SubTechniques=["subtechnique1"],
Platforms=["platform1"],
TechniqueURL="https://example.com",
)
requirement1.name = "Requirement 1"
requirement2 = MagicMock(
id="requirement2",
description="Description of requirement 2",
attributes=[],
checks={"aws": []},
tactics=[],
sub_techniques=[],
platforms=[],
technique_url="",
Id="requirement2",
Name="Requirement 2",
Description="Description of requirement 2",
Attributes=[],
Checks=[],
Tactics=[],
SubTechniques=[],
Platforms=[],
TechniqueURL="",
)
requirement2.name = "Requirement 2"
compliance1 = MagicMock(
requirements=[requirement1, requirement2],
framework="Framework 1",
version="1.0",
description="Description of compliance1",
Requirements=[requirement1, requirement2],
Framework="Framework 1",
Version="1.0",
Description="Description of compliance1",
Name="Compliance 1",
)
compliance1.name = "Compliance 1"
prowler_compliance = {"aws": {"compliance1": compliance1}}
template = generate_compliance_overview_template(prowler_compliance)
@@ -276,28 +271,24 @@ def reset_compliance_cache():
class TestGetComplianceFrameworks:
def test_returns_keys_from_compliance_get_bulk(self, reset_compliance_cache):
with patch(
"api.compliance.get_bulk_compliance_frameworks_universal"
) as mock_get_bulk:
mock_get_bulk.return_value = {
with patch("api.compliance.Compliance") as mock_compliance:
mock_compliance.get_bulk.return_value = {
"cis_1.4_aws": MagicMock(),
"mitre_attack_aws": MagicMock(),
}
result = get_compliance_frameworks(Provider.ProviderChoices.AWS)
assert sorted(result) == ["cis_1.4_aws", "mitre_attack_aws"]
mock_get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS)
mock_compliance.get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS)
def test_caches_result_per_provider(self, reset_compliance_cache):
with patch(
"api.compliance.get_bulk_compliance_frameworks_universal"
) as mock_get_bulk:
mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
with patch("api.compliance.Compliance") as mock_compliance:
mock_compliance.get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
get_compliance_frameworks(Provider.ProviderChoices.AWS)
get_compliance_frameworks(Provider.ProviderChoices.AWS)
# Cached after first call.
assert mock_get_bulk.call_count == 1
assert mock_compliance.get_bulk.call_count == 1
@pytest.mark.parametrize(
"provider_type",
@@ -305,19 +296,17 @@ class TestGetComplianceFrameworks:
)
def test_listing_is_subset_of_bulk(self, reset_compliance_cache, provider_type):
"""Regression for CLOUD-API-40S: every name returned by
``get_compliance_frameworks`` must be loadable via
``get_bulk_compliance_frameworks_universal``.
``get_compliance_frameworks`` must be loadable via ``Compliance.get_bulk``.
A divergence here is what produced ``KeyError: 'csa_ccm_4.0'`` in
``generate_outputs_task`` after universal/multi-provider compliance
JSONs were introduced at the top-level ``prowler/compliance/`` path.
"""
bulk_keys = set(get_bulk_compliance_frameworks_universal(provider_type).keys())
bulk_keys = set(Compliance.get_bulk(provider_type).keys())
listed = set(get_compliance_frameworks(provider_type))
missing = listed - bulk_keys
assert not missing, (
f"get_compliance_frameworks({provider_type!r}) returned names not "
f"loadable by get_bulk_compliance_frameworks_universal: "
f"{sorted(missing)}"
f"loadable by Compliance.get_bulk: {sorted(missing)}"
)
@@ -1,55 +0,0 @@
from config.django.base import label_postgres_connections
class TestLabelPostgresConnections:
def test_labels_postgres_and_skips_neo4j(self, monkeypatch):
monkeypatch.setenv("DJANGO_APP_COMPONENT", "scan")
databases = {
"default": {"ENGINE": "psqlextra.backend"},
"neo4j": {"HOST": "neo4j", "PORT": "7687"},
}
label_postgres_connections(databases)
assert databases["default"]["OPTIONS"]["application_name"] == "scan:default"
assert "OPTIONS" not in databases["neo4j"]
def test_labels_plain_postgresql_backend(self, monkeypatch):
monkeypatch.setenv("DJANGO_APP_COMPONENT", "api")
databases = {"saas": {"ENGINE": "django.db.backends.postgresql"}}
label_postgres_connections(databases)
assert databases["saas"]["OPTIONS"]["application_name"] == "api:saas"
def test_defaults_component_to_api_when_unset(self, monkeypatch):
monkeypatch.delenv("DJANGO_APP_COMPONENT", raising=False)
databases = {"default": {"ENGINE": "psqlextra.backend"}}
label_postgres_connections(databases)
assert databases["default"]["OPTIONS"]["application_name"] == "api:default"
def test_preserves_existing_options(self, monkeypatch):
monkeypatch.setenv("DJANGO_APP_COMPONENT", "worker")
databases = {
"replica": {
"ENGINE": "psqlextra.backend",
"OPTIONS": {"sslmode": "require"},
}
}
label_postgres_connections(databases)
assert databases["replica"]["OPTIONS"] == {
"sslmode": "require",
"application_name": "worker:replica",
}
def test_truncates_application_name_to_63_bytes(self, monkeypatch):
monkeypatch.setenv("DJANGO_APP_COMPONENT", "c" * 80)
databases = {"default": {"ENGINE": "psqlextra.backend"}}
label_postgres_connections(databases)
assert len(databases["default"]["OPTIONS"]["application_name"]) == 63
+13 -238
View File
@@ -24,11 +24,9 @@ from conftest import (
today_after_n_days,
)
from django.conf import settings
from django.db import connection
from django.db.models import Count
from django.http import JsonResponse
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django_celery_results.models import TaskResult
from rest_framework import status
@@ -66,7 +64,6 @@ from api.models import (
ProviderSecret,
Resource,
ResourceFindingMapping,
ResourceTag,
Role,
RoleProviderGroupRelationship,
SAMLConfiguration,
@@ -3859,20 +3856,16 @@ class TestScanViewSet:
scan.output_location = "dummy"
scan.save()
task_result = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
)
task = Task.objects.create(
tenant_id=scan.tenant_id,
task_runner_task=task_result,
)
dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING}
dummy_task = Task.objects.create(tenant_id=scan.tenant_id)
dummy_task.id = "dummy-task-id"
dummy_task_data = {"id": dummy_task.id, "state": StateChoices.EXECUTING}
with patch(
"api.v1.views.TaskSerializer",
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
with (
patch("api.v1.views.Task.objects.get", return_value=dummy_task),
patch(
"api.v1.views.TaskSerializer",
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
),
):
url = reverse("scan-report", kwargs={"pk": scan.id})
response = authenticated_client.get(url)
@@ -4193,88 +4186,6 @@ class TestScanViewSet:
assert resp.status_code == status.HTTP_302_FOUND
assert resp["Location"] == presigned_url
def test_compliance_s3_returns_latest_match(
self, authenticated_client, scans_fixture, monkeypatch
):
"""When several files match, the most recently modified one is served."""
scan = scans_fixture[0]
bucket = "bucket"
scan.output_location = f"s3://{bucket}/path/scan.zip"
scan.state = StateChoices.COMPLETED
scan.save()
monkeypatch.setattr(
"api.v1.views.env",
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
)
old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv"
latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv"
class FakeS3Client:
def list_objects_v2(self, Bucket, Prefix):
return {
"Contents": [
{
"Key": old_key,
"LastModified": datetime(2024, 1, 1, tzinfo=timezone.utc),
},
{
"Key": latest_key,
"LastModified": datetime(2024, 2, 2, tzinfo=timezone.utc),
},
]
}
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
assert Params["Key"] == latest_key
return "https://test-bucket.s3.amazonaws.com/latest"
monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())
url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"})
resp = authenticated_client.get(url)
assert resp.status_code == status.HTTP_302_FOUND
assert resp["Location"].endswith("/latest")
def test_compliance_local_returns_latest_match(
self, authenticated_client, scans_fixture, monkeypatch
):
"""The local branch serves the most recently modified matching file."""
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
with tempfile.TemporaryDirectory() as tmp:
comp_dir = Path(tmp) / "reports" / "compliance"
comp_dir.mkdir(parents=True, exist_ok=True)
old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv"
old_file.write_bytes(b"old")
latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv"
latest_file.write_bytes(b"latest")
# Make `latest_file` newer regardless of creation order.
os.utime(old_file, (1_700_000_000, 1_700_000_000))
os.utime(latest_file, (1_700_000_100, 1_700_000_100))
scan.output_location = str(Path(tmp) / "reports" / "scan.zip")
scan.save()
monkeypatch.setattr(
glob,
"glob",
lambda p: [str(old_file), str(latest_file)],
)
url = reverse(
"scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}
)
resp = authenticated_client.get(url)
assert resp.status_code == status.HTTP_200_OK
assert resp.content == b"latest"
assert resp["Content-Disposition"].endswith(
f'filename="{latest_file.name}"'
)
def test_compliance_s3_not_found(
self, authenticated_client, scans_fixture, monkeypatch
):
@@ -4383,24 +4294,18 @@ class TestScanViewSet:
assert cd.startswith('attachment; filename="')
assert cd.endswith(f'filename="{fname.name}"')
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.TaskSerializer")
def test__get_task_status_returns_none_if_task_not_executing(
self, mock_task_serializer, authenticated_client, scans_fixture
self, mock_task_serializer, mock_task_get, authenticated_client, scans_fixture
):
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
scan.output_location = "dummy"
scan.save()
task_result = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
)
task = Task.objects.create(
tenant_id=scan.tenant_id,
task_runner_task=task_result,
)
task = Task.objects.create(tenant_id=scan.tenant_id)
mock_task_get.return_value = task
mock_task_serializer.return_value.data = {
"id": str(task.id),
"state": StateChoices.COMPLETED,
@@ -4421,7 +4326,6 @@ class TestScanViewSet:
scan.save()
task_result = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
)
@@ -4442,51 +4346,6 @@ class TestScanViewSet:
assert response.status_code == status.HTTP_202_ACCEPTED
assert response.data["id"] == str(task.id)
@patch("api.v1.views.TaskSerializer")
def test__get_task_status_returns_latest_task(
self, mock_task_serializer, authenticated_client, scans_fixture
):
"""With several scan-report tasks for the scan, the most recent is used."""
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
scan.output_location = "dummy"
scan.save()
old_task = Task.objects.create(
tenant_id=scan.tenant_id,
task_runner_task=TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
),
)
new_task = Task.objects.create(
tenant_id=scan.tenant_id,
task_runner_task=TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
),
)
# `inserted_at` is `auto_now_add`, and within the test transaction the DB
# `now()` is constant, so force distinct timestamps to make order_by stable.
base = datetime(2024, 1, 1, tzinfo=timezone.utc)
Task.objects.filter(pk=old_task.pk).update(inserted_at=base)
Task.objects.filter(pk=new_task.pk).update(
inserted_at=base + timedelta(hours=1)
)
mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace(
data={"id": str(instance.id), "state": StateChoices.EXECUTING}
)
url = reverse("scan-report", kwargs={"pk": scan.id})
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_202_ACCEPTED
assert str(new_task.id) in response["Content-Location"]
assert str(old_task.id) not in response["Content-Location"]
@patch("api.v1.views.get_s3_client")
@patch("api.v1.views.sentry_sdk.capture_exception")
def test_compliance_list_objects_client_error(
@@ -7057,80 +6916,6 @@ class TestFindingViewSet:
== findings_fixture[0].status
)
def test_findings_list_resource_tags_no_n_plus_one(
self, authenticated_client, findings_fixture
):
"""Listing findings must load every resource's tags in a constant
number of queries, no matter how many findings/resources are returned.
This guards ``FindingViewSet._optimize_tags_loading`` against
regressions that would reintroduce one extra query per resource (the
N+1 the prefetch was added to remove).
"""
scan = findings_fixture[0].scan
tenant_id = findings_fixture[0].tenant_id
provider = scan.provider
def _create_finding_with_tagged_resource(index):
resource = Resource.objects.create(
tenant_id=tenant_id,
provider=provider,
uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}",
name=f"N+1 Instance {index}",
region="us-east-1",
service="ec2",
type="prowler-test",
)
resource.upsert_or_delete_tags(
[
ResourceTag.objects.create(
tenant_id=tenant_id,
key=f"key-{index}",
value=f"value-{index}",
)
]
)
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"n_plus_one_finding_{index}",
scan=scan,
status=Status.FAIL,
status_extended="n+1 status",
impact=Severity.medium,
severity=Severity.medium,
check_id="test_check_id",
check_metadata={"CheckId": "test_check_id", "servicename": "ec2"},
first_seen_at="2024-01-02T00:00:00Z",
)
finding.add_resources([resource])
return finding
params = {"filter[inserted_at]": TODAY, "include": "resources"}
# Baseline: the two findings provided by the fixture.
with CaptureQueriesContext(connection) as baseline:
response = authenticated_client.get(reverse("finding-list"), params)
assert response.status_code == status.HTTP_200_OK
# Add more findings, each with its own resource carrying tags.
extra_findings = 5
for index in range(extra_findings):
_create_finding_with_tagged_resource(index)
with CaptureQueriesContext(connection) as scaled:
response = authenticated_client.get(reverse("finding-list"), params)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(findings_fixture) + extra_findings
# The query count must not grow with the number of findings/resources.
assert len(scaled.captured_queries) == len(baseline.captured_queries), (
"Resource tags are not being prefetched: "
f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} "
f"findings vs {len(scaled.captured_queries)} for "
f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression "
"in FindingViewSet._optimize_tags_loading."
)
@pytest.mark.parametrize(
"include_values, expected_resources",
[
@@ -9560,16 +9345,6 @@ class TestComplianceOverviewViewSet:
assert "platforms" in attributes["attributes"]["technique_details"]
assert "technique_url" in attributes["attributes"]["technique_details"]
# Guard against the `_raw_attributes` wrapper leaking through —
# the UI reads metadata[i].Category / .AWSService directly.
metadata = attributes["attributes"]["metadata"]
assert isinstance(metadata, list) and len(metadata) > 0
first_attr = metadata[0]
assert isinstance(first_attr, dict)
assert "_raw_attributes" not in first_attr
assert "Category" in first_attr
assert "AWSService" in first_attr
def test_compliance_overview_attributes_missing_compliance_id(
self, authenticated_client
):
+56 -139
View File
@@ -116,7 +116,6 @@ from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
from api.compliance import (
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
get_compliance_frameworks,
get_prowler_provider_compliance,
)
from api.constants import SEVERITY_ORDER
from api.db_router import MainRouter
@@ -1850,42 +1849,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
200: OpenApiResponse(
description="CSV file containing the compliance report"
),
202: OpenApiResponse(description="The task is in progress"),
403: OpenApiResponse(description="There is a problem with credentials"),
404: OpenApiResponse(
description="Compliance report not found, or the scan has no reports yet"
),
},
request=None,
),
compliance_ocsf=extend_schema(
tags=["Scan"],
summary="Retrieve compliance report as OCSF JSON",
description=(
"Download a specific compliance report as an OCSF JSON file. "
"Only universal frameworks that declare an output configuration "
"produce this artifact (currently 'dora' and 'csa_ccm_4.0'); any "
"other framework returns 404."
),
parameters=[
OpenApiParameter(
name="name",
type=str,
location=OpenApiParameter.PATH,
required=True,
description="The compliance report name, like 'dora'",
),
],
responses={
200: OpenApiResponse(
description="OCSF JSON file containing the compliance report"
),
202: OpenApiResponse(description="The task is in progress"),
403: OpenApiResponse(description="There is a problem with credentials"),
404: OpenApiResponse(
description="Compliance report not found, the framework does "
"not provide an OCSF export, or the scan has no reports yet"
),
404: OpenApiResponse(description="Compliance report not found"),
},
request=None,
),
@@ -2028,23 +1992,35 @@ class ScanViewSet(BaseRLSViewSet):
return queryset.select_related("provider", "task")
def get_serializer_class(self):
if self.action == "partial_update":
return ScanUpdateSerializer
action_defaults = {
"create": ScanCreateSerializer,
"report": ScanReportSerializer,
"compliance": ScanComplianceReportSerializer,
"compliance_ocsf": ScanComplianceReportSerializer,
}
response_only_actions = {"threatscore", "ens", "nis2", "csa", "cis"}
if self.action in action_defaults or self.action in response_only_actions:
if self.action == "create":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
return ScanCreateSerializer
elif self.action == "partial_update":
return ScanUpdateSerializer
elif self.action == "report":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
return ScanReportSerializer
elif self.action == "compliance":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
return ScanComplianceReportSerializer
elif self.action == "threatscore":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
elif self.action == "ens":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
elif self.action == "nis2":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
elif self.action == "csa":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
elif self.action == "cis":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
if self.action in action_defaults:
return action_defaults[self.action]
return super().get_serializer_class()
def partial_update(self, request, *args, **kwargs):
@@ -2083,17 +2059,12 @@ class ScanViewSet(BaseRLSViewSet):
if scan_instance.state == StateChoices.EXECUTING and scan_instance.task:
task = scan_instance.task
else:
# A scan can have several `scan-report` tasks (e.g. re-runs); take the
# most recent one. `.first()` also avoids `MultipleObjectsReturned`.
task = (
Task.objects.filter(
try:
task = Task.objects.get(
task_runner_task__task_name="scan-report",
task_runner_task__task_kwargs__contains=str(scan_instance.id),
)
.order_by("-inserted_at")
.first()
)
if task is None:
except Task.DoesNotExist:
return None
self.response_serializer_class = TaskSerializer
@@ -2168,32 +2139,27 @@ class ScanViewSet(BaseRLSViewSet):
status=status.HTTP_502_BAD_GATEWAY,
)
contents = resp.get("Contents", [])
matches = []
keys = []
for obj in contents:
key = obj["Key"]
key_basename = os.path.basename(key)
if any(ch in suffix for ch in ("*", "?", "[")):
if fnmatch.fnmatch(key_basename, suffix):
matches.append(obj)
keys.append(key)
elif key_basename == suffix:
matches.append(obj)
keys.append(key)
elif key.endswith(suffix):
# Backward compatibility if suffix already includes directories
matches.append(obj)
if not matches:
keys.append(key)
if not keys:
return Response(
{
"detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'."
},
status=status.HTTP_404_NOT_FOUND,
)
# Return the most recently modified match (latest report) when
# several files share the prefix/suffix. `list_objects_v2` always
# returns `LastModified`; the fallback keeps ordering deterministic
# if it is ever absent.
key = max(matches, key=lambda o: (o.get("LastModified", ""), o["Key"]))[
"Key"
]
# path_pattern here is prefix, but in compliance we build correct suffix check before
key = keys[0]
else:
# path_pattern is exact key; HEAD before presigning to preserve the 404 contract.
key = path_pattern
@@ -2243,9 +2209,7 @@ class ScanViewSet(BaseRLSViewSet):
},
status=status.HTTP_404_NOT_FOUND,
)
# Return the most recently modified match (latest report) when the
# pattern resolves to several files.
filepath = max(files, key=os.path.getmtime)
filepath = files[0]
with open(filepath, "rb") as f:
content = f.read()
filename = os.path.basename(filepath)
@@ -2293,16 +2257,20 @@ class ScanViewSet(BaseRLSViewSet):
content, filename = loader
return self._serve_file(content, filename, "application/x-zip-compressed")
def _serve_compliance_artifact(self, scan, name, file_extension, content_type):
"""Resolve and serve a per-framework compliance artifact from disk/S3.
@action(
detail=True,
methods=["get"],
url_path="compliance/(?P<name>[^/]+)",
url_name="compliance",
)
def compliance(self, request, pk=None, name=None):
scan = self.get_object()
if name not in get_compliance_frameworks(scan.provider.provider):
return Response(
{"detail": f"Compliance '{name}' not found."},
status=status.HTTP_404_NOT_FOUND,
)
Shared by the CSV and OCSF compliance download actions. Both are
path-based (no query params) on purpose: ``get_object`` runs
``filter_queryset``, which triggers JSON:API's
``QueryParameterValidationFilter`` and 400s on any non-JSON:API
query param, so a ``?format=`` / ``?type=`` selector is not viable
here the format is encoded in the route instead.
"""
running_resp = self._get_task_status(scan)
if running_resp:
return running_resp
@@ -2319,66 +2287,25 @@ class ScanViewSet(BaseRLSViewSet):
bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/")
prefix = os.path.join(
os.path.dirname(key_prefix), "compliance", f"{name}.{file_extension}"
os.path.dirname(key_prefix), "compliance", f"{name}.csv"
)
loader = self._load_file(
prefix,
s3=True,
bucket=bucket,
list_objects=True,
content_type=content_type,
content_type="text/csv",
)
else:
base = os.path.dirname(scan.output_location)
pattern = os.path.join(base, "compliance", f"*_{name}.{file_extension}")
pattern = os.path.join(base, "compliance", f"*_{name}.csv")
loader = self._load_file(pattern, s3=False)
if isinstance(loader, HttpResponseBase):
return loader
content, filename = loader
return self._serve_file(content, filename, content_type)
@action(
detail=True,
methods=["get"],
url_path="compliance/(?P<name>[^/]+)",
url_name="compliance",
)
def compliance(self, request, pk=None, name=None):
scan = self.get_object()
if name not in get_compliance_frameworks(scan.provider.provider):
return Response(
{"detail": f"Compliance '{name}' not found."},
status=status.HTTP_404_NOT_FOUND,
)
return self._serve_compliance_artifact(scan, name, "csv", "text/csv")
@action(
detail=True,
methods=["get"],
url_path="compliance/(?P<name>[^/]+)/ocsf",
url_name="compliance-ocsf",
)
def compliance_ocsf(self, request, pk=None, name=None):
scan = self.get_object()
if name not in get_compliance_frameworks(scan.provider.provider):
return Response(
{"detail": f"Compliance '{name}' not found."},
status=status.HTTP_404_NOT_FOUND,
)
universal_bulk = get_prowler_provider_compliance(scan.provider.provider)
framework_obj = universal_bulk.get(name)
if not (framework_obj and getattr(framework_obj, "outputs", None)):
return Response(
{"detail": f"Compliance '{name}' does not provide an OCSF export."},
status=status.HTTP_404_NOT_FOUND,
)
return self._serve_compliance_artifact(
scan, name, "ocsf.json", "application/json"
)
return self._serve_file(content, filename, "text/csv")
@action(
detail=True,
@@ -3822,16 +3749,6 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
return queryset
return super().filter_queryset(queryset)
def _optimize_tags_loading(self, queryset):
"""Prefetch resource tags to avoid N+1 queries when serializing findings"""
return queryset.prefetch_related(
Prefetch(
"resources__tags",
queryset=ResourceTag.objects.filter(tenant_id=self.request.tenant_id),
to_attr="prefetched_tags",
)
)
def list(self, request, *args, **kwargs):
filtered_queryset = self.filter_queryset(self.get_queryset())
return self.paginate_by_pk(
-55
View File
@@ -26,61 +26,6 @@ celery_app.conf.result_backend_transport_options = {
}
celery_app.conf.visibility_timeout = BROKER_VISIBILITY_TIMEOUT
# Durable delivery: keep the message until the task finishes, so a worker killed
# mid-task (deploy/OOM/eviction) does not silently drop it. Reserve one task at a
# time so a crash exposes at most one extra reserved message.
celery_app.conf.task_acks_late = True
celery_app.conf.task_reject_on_worker_lost = True
celery_app.conf.worker_prefetch_multiplier = env.int(
"DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER", default=1
)
# On SIGTERM, give the worker time to finish or re-queue in-flight tasks before
# it is forcefully killed (Celery 5.5+ soft shutdown).
celery_app.conf.worker_soft_shutdown_timeout = env.int(
"DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT", default=60
)
# Bound execution so a blocked task cannot pin a worker forever. Connection
# checks get a tight limit; scans and provider/tenant deletions can legitimately
# run for more than a day on large tenants, so they get a much higher cap.
# The default for every other task is set as the global limit, not as a "*"
# annotation: Celery applies the "*" entry AFTER the per-task one, so a "*" in
# task_annotations would silently overwrite every specific limit defined below.
_TASK_HARD_LIMIT = env.int("DJANGO_CELERY_TASK_TIME_LIMIT", default=6 * 60 * 60)
_TASK_SOFT_LIMIT = env.int(
"DJANGO_CELERY_TASK_SOFT_TIME_LIMIT", default=_TASK_HARD_LIMIT - 600
)
_LONG_TASK_HARD_LIMIT = env.int(
"DJANGO_CELERY_LONG_TASK_TIME_LIMIT", default=48 * 60 * 60
)
_LONG_TASK_SOFT_LIMIT = env.int(
"DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT", default=_LONG_TASK_HARD_LIMIT - 600
)
celery_app.conf.task_time_limit = _TASK_HARD_LIMIT
celery_app.conf.task_soft_time_limit = _TASK_SOFT_LIMIT
celery_app.conf.task_annotations = {
**{
name: {"soft_time_limit": 60, "time_limit": 120}
for name in (
"provider-connection-check",
"integration-connection-check",
"lighthouse-connection-check",
"lighthouse-provider-connection-check",
)
},
**{
name: {
"soft_time_limit": _LONG_TASK_SOFT_LIMIT,
"time_limit": _LONG_TASK_HARD_LIMIT,
}
for name in (
"scan-perform",
"scan-perform-scheduled",
"provider-deletion",
"tenant-deletion",
)
},
}
celery_app.autodiscover_tasks(["api"])
-29
View File
@@ -306,32 +306,3 @@ SESSION_COOKIE_SECURE = True
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
) # 48h
# Orphan task recovery feature flags. The master switch is OFF by default, so task
# recovery is opt-in; enable it with DJANGO_TASK_RECOVERY_ENABLED=true. The per-group
# toggles default to enabled, so once the master is on every group recovers unless a
# group is explicitly turned off.
TASK_RECOVERY_ENABLED = env.bool("DJANGO_TASK_RECOVERY_ENABLED", False)
TASK_RECOVERY_SUMMARIES_ENABLED = env.bool(
"DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED", True
)
TASK_RECOVERY_DELETIONS_ENABLED = env.bool(
"DJANGO_TASK_RECOVERY_DELETIONS_ENABLED", True
)
def label_postgres_connections(databases):
"""Tag each Postgres connection with ``application_name="<component>:<alias>"``
so connections are attributable by component in ``pg_stat_activity`` (and any
tooling that surfaces ``application_name``). The component (api / worker /
scan / ...) is injected per process by the container entrypoint via
``DJANGO_APP_COMPONENT``; the alias distinguishes which pool inside the
process owns the connection. The neo4j entry is skipped (not a Postgres
backend). Postgres truncates ``application_name`` at 63 bytes.
"""
component = env.str("DJANGO_APP_COMPONENT", default="api")
for alias, config in databases.items():
engine = config.get("ENGINE", "")
if engine.startswith("psqlextra") or "postgresql" in engine:
name = f"{component}:{alias}"[:63]
config.setdefault("OPTIONS", {})["application_name"] = name
-2
View File
@@ -54,8 +54,6 @@ DATABASES = {
DATABASES["default"] = DATABASES["prowler_user"]
label_postgres_connections(DATABASES) # noqa: F405
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405
render_class
for render_class in REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] # noqa: F405
@@ -58,5 +58,3 @@ DATABASES = {
}
DATABASES["default"] = DATABASES["prowler_user"]
label_postgres_connections(DATABASES) # noqa: F405
-5
View File
@@ -34,8 +34,3 @@ DRF_API_KEY = {
# JWT
SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405
# pyjwt >= 2.13.0 rejects an empty HMAC signing key, so HS256 tests need a real
# key (>= 32 bytes also avoids the InsecureKeyLengthWarning). Production uses RS256.
SIMPLE_JWT["SIGNING_KEY"] = env.str( # noqa: F405
"DJANGO_TOKEN_SIGNING_KEY", "insecure-testing-jwt-signing-key-do-not-use-in-prod"
)
@@ -1,14 +1,12 @@
from datetime import datetime, timedelta, timezone
from celery import states
from celery import current_app, states
from celery.utils.log import get_task_logger
from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES
from tasks.jobs.attack_paths.db_utils import (
_mark_scan_finished,
recover_graph_data_ready,
)
from tasks.jobs.orphan_recovery import is_worker_alive as _is_worker_alive
from tasks.jobs.orphan_recovery import revoke_task as _revoke_task
from api.attack_paths import database as graph_database
from api.db_router import MainRouter
@@ -152,6 +150,32 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]:
return cleaned_up
def _is_worker_alive(worker: str) -> bool:
"""Ping a specific Celery worker. Returns `True` if it responds or on error."""
try:
response = current_app.control.inspect(destination=[worker], timeout=1.0).ping()
return response is not None and worker in response
except Exception:
logger.exception(f"Failed to ping worker {worker}, treating as alive")
return True
def _revoke_task(task_result, terminate: bool = True) -> None:
"""Revoke a Celery task. Non-fatal on failure.
`terminate=True` SIGTERMs the worker if the task is mid-execution; use
for EXECUTING cleanup. `terminate=False` only marks the task id revoked
across workers, so any worker pulling the queued message discards it;
use for SCHEDULED cleanup where the task hasn't run yet.
"""
try:
kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {}
current_app.control.revoke(task_result.task_id, **kwargs)
logger.info(f"Revoked task {task_result.task_id}")
except Exception:
logger.exception(f"Failed to revoke task {task_result.task_id}")
def _cleanup_scan(scan, task_result, reason: str) -> bool:
"""
Clean up a single stale `AttackPathsScan`:
+10
View File
@@ -39,6 +39,11 @@ from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import (
GoogleWorkspaceCISASCuBA,
)
from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA
from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA
from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA
from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS
from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS
from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS
@@ -97,6 +102,7 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name == "prowler_threatscore_aws", ProwlerThreatScoreAWS),
(lambda name: name.startswith("ccc_"), CCC_AWS),
(lambda name: name.startswith("c5_"), AWSC5),
(lambda name: name.startswith("csa_"), AWSCSA),
(lambda name: name == "asd_essential_eight_aws", ASDEssentialEightAWS),
],
"azure": [
@@ -107,6 +113,7 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name.startswith("ccc_"), CCC_Azure),
(lambda name: name == "prowler_threatscore_azure", ProwlerThreatScoreAzure),
(lambda name: name == "c5_azure", AzureC5),
(lambda name: name.startswith("csa_"), AzureCSA),
],
"gcp": [
(lambda name: name.startswith("cis_"), GCPCIS),
@@ -116,6 +123,7 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP),
(lambda name: name.startswith("ccc_"), CCC_GCP),
(lambda name: name == "c5_gcp", GCPC5),
(lambda name: name.startswith("csa_"), GCPCSA),
],
"kubernetes": [
(lambda name: name.startswith("cis_"), KubernetesCIS),
@@ -144,9 +152,11 @@ COMPLIANCE_CLASS_MAP = {
"image": [],
"oraclecloud": [
(lambda name: name.startswith("cis_"), OracleCloudCIS),
(lambda name: name.startswith("csa_"), OracleCloudCSA),
],
"alibabacloud": [
(lambda name: name.startswith("cis_"), AlibabaCloudCIS),
(lambda name: name.startswith("csa_"), AlibabaCloudCSA),
(
lambda name: name == "prowler_threatscore_alibabacloud",
ProwlerThreatScoreAlibaba,
@@ -1,341 +0,0 @@
"""Detect and recover orphaned Celery tasks.
A task is "orphaned" when its result row is non-terminal (STARTED/RECEIVED) but the
worker that was running it is gone (deploy, OOM, eviction). We tell a real orphan
from a still-running task by pinging the worker recorded on its `TaskResult`:
- worker responds -> the task is in flight, leave it alone (never double-run);
- worker is gone -> real orphan: mark the stale result terminal (so pending/started
alerts clear), then re-enqueue the task from its stored name + kwargs.
This recovers only allowlisted tasks with local, proven idempotency. Celery's
`result_extended=True` gives us the stored `task_name`/`task_kwargs`/`worker` once
the task starts, but external side-effect tasks are failed instead of blindly
re-run. A small recovery cap stops a task that repeatedly kills its worker from
looping forever.
This is the shared engine behind both the periodic Beat watchdog and the
`reconcile_orphan_tasks` management command.
"""
import ast
import json
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from celery import current_app, states
from celery.utils.log import get_task_logger
from django.db import connections
logger = get_task_logger(__name__)
# Arbitrary constant key for pg_try_advisory_lock so only one reconciliation
# runs at a time across replicas / the watchdog / the command.
ORPHAN_RECOVERY_LOCK_KEY = 0x70726F77 # "prow"
# Non-terminal states that mean "a worker had this and may have died with it".
IN_FLIGHT_STATES = (states.STARTED, states.RECEIVED)
# Tasks with proven idempotency are eligible for auto re-enqueue, grouped so each
# group can be toggled independently by a feature flag (see config.django.base).
# Summaries clear and rewrite their own rows and deletions are idempotent. Tasks with
# external side effects are never eligible: integration-jira would create duplicate
# issues, integration-s3 rebuilds its upload from worker-local files that do not
# survive a crash, and report/Security Hub recovery is out of scope.
RECOVERY_TASK_GROUPS = {
"summaries": {
"scan-summary",
"scan-compliance-overviews",
"scan-provider-compliance-scores",
"scan-daily-severity",
"scan-finding-group-summaries",
"scan-reset-ephemeral-resources",
},
"deletions": {"provider-deletion", "tenant-deletion"},
}
def reenqueueable_tasks() -> set[str]:
"""Task names eligible for auto re-enqueue, honoring the per-group feature flags.
A group whose flag is disabled is dropped, so its orphaned tasks are marked
terminal instead of re-enqueued.
"""
from django.conf import settings
group_enabled = {
"summaries": settings.TASK_RECOVERY_SUMMARIES_ENABLED,
"deletions": settings.TASK_RECOVERY_DELETIONS_ENABLED,
}
return {
task
for group, tasks in RECOVERY_TASK_GROUPS.items()
if group_enabled[group]
for task in tasks
}
# Tasks the watchdog ignores entirely (not even marked terminal): scan tasks are not
# auto-recovered, since re-running a scan is not safe to do automatically; attack-paths
# scans are handled by their own stale-cleanup (which also drops the temp Neo4j db);
# and the maintenance tasks must not self-recover (they run again on their own schedule).
_SKIP_RECOVERY = {
"scan-perform",
"scan-perform-scheduled",
"attack-paths-scan-perform",
"attack-paths-cleanup-stale-scans",
"reconcile-orphan-tasks",
}
@contextmanager
def advisory_lock(key: int = ORPHAN_RECOVERY_LOCK_KEY, using: str = "default"):
"""Yield True if this session won a Postgres advisory lock, else False.
Non-blocking: losers get False and should no-op. The lock is released on
exit (and implicitly if the session dies).
"""
with connections[using].cursor() as cursor:
cursor.execute("SELECT pg_try_advisory_lock(%s)", [key])
acquired = bool(cursor.fetchone()[0])
try:
yield acquired
finally:
if acquired:
cursor.execute("SELECT pg_advisory_unlock(%s)", [key])
def is_worker_alive(worker: str, timeout: float = 1.0) -> bool:
"""Ping a specific Celery worker. Returns True if it responds, or on error.
Erring on the side of "alive" means an unreachable control bus never causes
a still-running task to be re-enqueued.
"""
try:
response = current_app.control.inspect(
destination=[worker], timeout=timeout
).ping()
return response is not None and worker in response
except Exception:
logger.exception(f"Failed to ping worker {worker}, treating as alive")
return True
def revoke_task(task_result, terminate: bool = True) -> None:
"""Revoke a Celery task by its TaskResult. Non-fatal on failure.
terminate=True SIGTERMs the worker if the task is mid-execution; terminate=False
only marks the id revoked so any worker pulling the queued message discards it
(use before re-enqueuing, so a later broker redelivery of the stale message is
dropped).
"""
try:
kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {}
current_app.control.revoke(task_result.task_id, **kwargs)
logger.info(f"Revoked task {task_result.task_id}")
except Exception:
logger.exception(f"Failed to revoke task {task_result.task_id}")
def _decode_celery_field(value, default):
"""Decode django-celery-results' stored task_args/task_kwargs to a Python object.
The backend stores them as a (sometimes double-encoded) repr/JSON string. An
empty or missing field returns ``default``; a non-empty value that cannot be
decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with
the wrong arguments.
"""
obj = value
for _ in range(2): # values can be double-encoded (a string holding a repr)
if not isinstance(obj, str):
break
text = obj.strip()
if not text:
return default
parsed = None
for parser in (ast.literal_eval, json.loads):
try:
parsed = parser(text)
break
except (ValueError, SyntaxError, TypeError):
continue
if parsed is None:
raise ValueError(f"undecodable celery field: {text[:120]!r}")
obj = parsed
return default if obj is None else obj
def reconcile_orphans(
grace_minutes: int = 2,
max_attempts: int = 3,
window_hours: int = 6,
dry_run: bool = False,
) -> dict:
"""Run the full orphan sweep under a single-flight advisory lock.
Recovers any orphaned in-flight task and delegates attack-paths scans that
never reached a worker to their existing stale-cleanup. Returns a summary;
a no-op (lock not won) is reported too.
"""
with advisory_lock() as acquired:
if not acquired:
logger.info("Orphan reconcile skipped: another run holds the lock")
return {"acquired": False}
from django.conf import settings
if settings.TASK_RECOVERY_ENABLED:
# Populate the task registry so we can re-enqueue any task by name.
import tasks.tasks # noqa: F401
result = _reconcile_task_results(
grace_minutes=grace_minutes,
max_attempts=max_attempts,
window_hours=window_hours,
dry_run=dry_run,
)
result["enabled"] = True
else:
logger.info("Orphan task recovery disabled by feature flag")
result = {"recovered": [], "failed": [], "skipped": [], "enabled": False}
if not dry_run:
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
result["attack_paths"] = cleanup_stale_attack_paths_scans()
return {"acquired": True, **result}
def _reconcile_task_results(
grace_minutes: int, max_attempts: int, window_hours: int, dry_run: bool
) -> dict:
from django_celery_results.models import TaskResult
cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=grace_minutes)
candidates = list(
TaskResult.objects.filter(status__in=IN_FLIGHT_STATES, date_created__lt=cutoff)
.exclude(worker__isnull=True)
.exclude(worker="")
.exclude(task_name__in=_SKIP_RECOVERY)
)
# Ping each distinct worker at most once.
worker_alive = {w: is_worker_alive(w) for w in {tr.worker for tr in candidates}}
recovered, failed, skipped = [], [], []
for task_result in candidates:
if worker_alive.get(task_result.worker, True):
skipped.append(task_result.task_id) # in flight, do not double-run
continue
if dry_run:
recovered.append(task_result.task_id)
continue
outcome = _recover_task(task_result, max_attempts, window_hours)
(recovered if outcome == "recovered" else failed).append(task_result.task_id)
logger.info(
"Orphan reconcile: recovered=%d failed=%d skipped(in-flight)=%d",
len(recovered),
len(failed),
len(skipped),
)
return {"recovered": recovered, "failed": failed, "skipped": skipped}
def _recovery_attempt_count(name: str, kwargs_repr, window_hours: int) -> int:
"""Increment and return the recovery count for this (task, kwargs) within the
window. Backed by Valkey so it survives result-row churn (a worker processing
the revoke can blank the TaskResult fields). Fail-open if Valkey is down (the
broker being unreachable means nothing is running anyway).
"""
import hashlib
from django.conf import settings
try:
import redis
client = redis.from_url(settings.CELERY_BROKER_URL)
signature = f"{name}|{kwargs_repr}".encode()
key = (
"orphan-recovery:"
+ hashlib.sha1(signature, usedforsecurity=False).hexdigest()
)
count = client.incr(key)
if count == 1:
client.expire(key, max(1, window_hours) * 3600)
return int(count)
except Exception:
logger.exception("Recovery-attempt counter unavailable; allowing recovery")
return 1
def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
"""Recover one orphaned task. Returns 'recovered' or 'failed'."""
# Capture name/args/kwargs now: revoking can let a worker blank the row.
name = task_result.task_name
args_repr = task_result.task_args
kwargs_repr = task_result.task_kwargs
now = datetime.now(tz=timezone.utc)
# Drop any future broker redelivery of the stale message.
revoke_task(task_result, terminate=False)
# Mark the stale result terminal so "pending/started forever" alerts clear.
task_result.status = states.REVOKED
task_result.date_done = now
task_result.save(update_fields=["status", "date_done"])
if name not in reenqueueable_tasks():
logger.warning(
"Orphan %s (%s) not re-enqueued: not allowlisted for auto recovery",
task_result.task_id,
name,
)
return "failed"
# Count the attempt only once the task is allowlisted, so a task sitting in a
# disabled group does not burn its recovery budget while the flag is off (and is
# not already over the cap the moment the group is re-enabled).
attempt = _recovery_attempt_count(name, kwargs_repr, window_hours)
if attempt > max_attempts:
logger.warning(
"Orphan %s (%s) not re-enqueued: recovery cap reached (%d/%d)",
task_result.task_id,
name,
attempt,
max_attempts,
)
return "failed"
task_obj = current_app.tasks.get(name)
if task_obj is None:
logger.error(
"Orphan %s: task %s not registered, cannot re-enqueue",
task_result.task_id,
name,
)
return "failed"
try:
args = _decode_celery_field(args_repr, [])
kwargs = _decode_celery_field(kwargs_repr, {})
except ValueError:
logger.error(
"Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing",
task_result.task_id,
name,
)
return "failed"
new_task_id = str(uuid4())
task_obj.apply_async(
args=list(args) if isinstance(args, (list, tuple)) else [],
kwargs=kwargs if isinstance(kwargs, dict) else {},
task_id=new_task_id,
)
logger.info(
"Re-enqueued orphan %s (%s) as %s", task_result.task_id, name, new_task_id
)
return "recovered"
+6 -11
View File
@@ -29,10 +29,7 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import rls_transaction
from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
from api.utils import initialize_prowler_provider
from prowler.lib.check.compliance_models import (
Compliance,
get_bulk_compliance_frameworks_universal,
)
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.finding import Finding as FindingOutput
logger = get_task_logger(__name__)
@@ -574,7 +571,7 @@ def generate_csa_report(
Args:
tenant_id: The tenant ID for Row-Level Security context.
scan_id: ID of the scan executed by Prowler.
compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0").
compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0_aws").
output_path: Output PDF file path.
provider_id: Provider ID for the scan.
only_failed: If True, only include failed requirements in detailed section.
@@ -886,11 +883,9 @@ def generate_compliance_reports(
frameworks_bulk.get(f"nis2_{provider_type}")
)
if generate_csa:
# csa_ccm_4.0 lives at the top level, not under compliance/{provider}/.
csa_framework = frameworks_bulk.get(
"csa_ccm_4.0"
) or get_bulk_compliance_frameworks_universal(provider_type).get("csa_ccm_4.0")
pending_checks_by_framework["csa"] = _get_compliance_check_ids(csa_framework)
pending_checks_by_framework["csa"] = _get_compliance_check_ids(
frameworks_bulk.get(f"csa_ccm_4.0_{provider_type}")
)
if generate_cis and latest_cis:
pending_checks_by_framework["cis"] = _get_compliance_check_ids(
frameworks_bulk.get(latest_cis)
@@ -1188,7 +1183,7 @@ def generate_compliance_reports(
if generate_csa:
generated_report_keys.append("csa")
csa_path = output_paths["csa"]
compliance_id_csa = "csa_ccm_4.0"
compliance_id_csa = f"csa_ccm_4.0_{provider_type}"
pdf_path_csa = f"{csa_path}_csa_report.pdf"
logger.info("Generating CSA CCM report with compliance %s", compliance_id_csa)
+4 -57
View File
@@ -5,7 +5,6 @@ import time
from abc import ABC, abstractmethod
from contextlib import contextmanager
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any
from celery.utils.log import get_task_logger
@@ -27,10 +26,7 @@ from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
from api.models import Provider, StatusChoices
from api.utils import initialize_prowler_provider
from prowler.lib.check.compliance_models import (
Compliance,
get_bulk_compliance_frameworks_universal,
)
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.finding import Finding as FindingOutput
from .components import (
@@ -226,46 +222,6 @@ def get_requirement_metadata(
return None
def _universal_attributes_to_list(attributes) -> list:
"""Flatten a universal requirement's ``attributes`` into a list of objects
with attribute access. MITRE wraps its list under ``_raw_attributes``."""
if isinstance(attributes, dict) and "_raw_attributes" in attributes:
entries = attributes.get("_raw_attributes") or []
return [
SimpleNamespace(**entry) for entry in entries if isinstance(entry, dict)
]
if isinstance(attributes, dict):
return [SimpleNamespace(**attributes)] if attributes else []
return list(attributes or [])
def _adapt_universal_to_legacy(framework, provider_type: str) -> SimpleNamespace:
"""Expose a universal ``ComplianceFramework`` under the legacy ``Compliance``
attribute names used by the PDF pipeline."""
provider_key = (provider_type or "").lower()
requirements = []
for requirement in framework.requirements:
checks_by_provider = (
requirement.checks if isinstance(requirement.checks, dict) else {}
)
requirements.append(
SimpleNamespace(
Id=requirement.id,
Description=requirement.description or "",
Checks=list(checks_by_provider.get(provider_key, [])),
Attributes=_universal_attributes_to_list(requirement.attributes),
)
)
return SimpleNamespace(
Framework=framework.framework,
Name=framework.name,
Version=framework.version or "",
Description=framework.description or "",
Provider=framework.provider or provider_type,
Requirements=requirements,
)
# =============================================================================
# PDF Styles Cache
# =============================================================================
@@ -913,18 +869,9 @@ class BaseComplianceReportGenerator(ABC):
prowler_provider = initialize_prowler_provider(provider_obj)
provider_type = provider_obj.provider
# Load compliance framework — fall back to the universal loader
# for top-level JSONs (e.g. csa_ccm_4.0) that Compliance.get_bulk
# does not scan.
compliance_obj = Compliance.get_bulk(provider_type).get(compliance_id)
if not compliance_obj:
universal_framework = get_bulk_compliance_frameworks_universal(
provider_type
).get(compliance_id)
if universal_framework:
compliance_obj = _adapt_universal_to_legacy(
universal_framework, provider_type
)
# Load compliance framework
frameworks_bulk = Compliance.get_bulk(provider_type)
compliance_obj = frameworks_bulk.get(compliance_id)
if not compliance_obj:
raise ValueError(f"Compliance framework not found: {compliance_id}")
+3 -10
View File
@@ -476,12 +476,9 @@ def _create_compliance_summaries(
)
)
# Idempotent re-run: clear this scan's prior summaries before re-inserting, so a
# recovered scan-compliance-overviews run reflects its own re-derived rows instead
# of keeping a stale one (bulk_create ignore_conflicts alone would keep the old).
with rls_transaction(tenant_id):
ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete()
if summary_objects:
# Bulk insert summaries
if summary_objects:
with rls_transaction(tenant_id):
ComplianceOverviewSummary.objects.bulk_create(
summary_objects, batch_size=500, ignore_conflicts=True
)
@@ -1654,10 +1651,6 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
elif requirement_status == "PASS":
requirement_statuses[key]["pass_count"] += 1
# Idempotent re-run: COPY can't ON CONFLICT, so clear this scan's rows first.
with rls_transaction(tenant_id):
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
# Bulk create requirement records using PostgreSQL COPY
_persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows)
+22 -27
View File
@@ -359,40 +359,35 @@ def _load_findings_for_requirement_checks(
def _get_compliance_check_ids(compliance_obj) -> set[str]:
"""Return the union of all check_ids referenced by a compliance framework.
Used by the master report orchestrator to evict entries from
``findings_cache`` once no pending framework needs them (PROWLER-1733).
Used by the master report orchestrator to know which checks each
framework consumes from the shared ``findings_cache``, so that once a
framework finishes the entries no other pending framework needs can be
evicted from the cache (PROWLER-1733).
Accepts the legacy ``Compliance`` shape (``Requirements`` / ``Checks``
lists) and the universal ``ComplianceFramework`` shape (``requirements``
/ ``checks`` dict keyed by provider). ``None`` returns an empty set so
callers can pass ``frameworks_bulk.get(...)`` directly.
Args:
compliance_obj: A loaded Compliance framework object exposing a
``Requirements`` iterable, each requirement carrying ``Checks``.
``None`` is treated as "no checks" rather than raising, so the
caller can pass ``frameworks_bulk.get(...)`` directly without
an extra existence check.
Returns:
Set of check_id strings (empty if ``compliance_obj`` is ``None``).
"""
if compliance_obj is None:
return set()
requirements = getattr(compliance_obj, "Requirements", None) or getattr(
compliance_obj, "requirements", None
)
if not requirements:
return set()
check_ids: set[str] = set()
checks: set[str] = set()
requirements = getattr(compliance_obj, "Requirements", None) or []
try:
# Mock objects in unit tests return another Mock for any attribute
# access — truthy but not iterable. Treat that as "no checks".
for requirement in requirements:
requirement_checks = getattr(requirement, "Checks", None)
if requirement_checks is None:
checks_by_provider = getattr(requirement, "checks", None) or {}
requirement_checks = [
check_id
for check_ids_list in checks_by_provider.values()
for check_id in check_ids_list
]
# Defensive: Mock objects (used in unit tests) return another Mock
# for any attribute access, which is truthy but not iterable. Treat
# any non-iterable Requirements value as "no checks".
for req in requirements:
req_checks = getattr(req, "Checks", None) or []
try:
check_ids.update(requirement_checks)
checks.update(req_checks)
except TypeError:
continue
except TypeError:
return set()
return check_ids
return checks
+6 -92
View File
@@ -46,7 +46,6 @@ from tasks.jobs.lighthouse_providers import (
refresh_lighthouse_provider_models,
)
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.orphan_recovery import reconcile_orphans
from tasks.jobs.report import (
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
_cleanup_stale_tmp_output_directories,
@@ -68,10 +67,7 @@ from tasks.utils import (
get_next_execution_datetime,
)
from api.compliance import (
get_compliance_frameworks,
get_prowler_provider_compliance,
)
from api.compliance import get_compliance_frameworks
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import delete_related_daily_task, rls_transaction
from api.decorators import handle_provider_deletion, set_tenant
@@ -79,9 +75,6 @@ from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateC
from api.utils import initialize_prowler_provider
from api.v1.serializers import ScanTaskSerializer
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance import (
process_universal_compliance_frameworks,
)
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
from prowler.lib.outputs.finding import Finding as FindingOutput
@@ -260,9 +253,7 @@ def delete_provider_task(provider_id: str, tenant_id: str):
return delete_provider(tenant_id=tenant_id, pk=provider_id)
# acks_late=False: a re-run would duplicate findings and the task is not auto-recovered,
# so a crashed scan is dropped rather than redelivered by the broker (as before #11416).
@shared_task(base=RLSTask, name="scan-perform", queue="scans", acks_late=False)
@shared_task(base=RLSTask, name="scan-perform", queue="scans")
@handle_provider_deletion
def perform_scan_task(
tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None
@@ -306,14 +297,7 @@ def perform_scan_task(
return result
# acks_late=False: like scan-perform; a dropped run is re-fired by Beat on the next tick.
@shared_task(
base=RLSTask,
bind=True,
name="scan-perform-scheduled",
queue="scans",
acks_late=False,
)
@shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans")
@handle_provider_deletion
def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
"""
@@ -478,42 +462,13 @@ def cleanup_stale_attack_paths_scans_task():
return cleanup_stale_attack_paths_scans()
@shared_task(name="reconcile-orphan-tasks", queue="celery")
def reconcile_orphan_tasks_task():
"""Periodic watchdog: recover tasks whose worker is gone (deploys, crashes)."""
return reconcile_orphans()
@shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,))
def delete_tenant_task(tenant_id: str):
return delete_tenant(pk=tenant_id)
def _scan_tmp_output_directory(tenant_id: str, scan_id: str) -> Path:
"""Root tmp output directory for a scan ({tmp}/{tenant_id}/{scan_id})."""
return Path(DJANGO_TMP_OUTPUT_DIRECTORY) / str(tenant_id) / str(scan_id)
class ScanReportRLSTask(RLSTask):
"""
RLS task that removes the scan's tmp output directory when the task fails.
Covers failures both inside and outside the task body (e.g. ENOSPC mid-write,
or setup errors) so partial artifacts do not accumulate on the worker disk.
"""
def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002
del args # Required by Celery's Task.on_failure signature; not used.
tenant_id = kwargs.get("tenant_id")
scan_id = kwargs.get("scan_id")
if tenant_id and scan_id:
logger.error(f"Scan report task {task_id} failed: {exc}")
rmtree(_scan_tmp_output_directory(tenant_id, scan_id), ignore_errors=True)
@shared_task(
base=ScanReportRLSTask,
base=RLSTask,
name="scan-report",
queue="scan-reports",
)
@@ -558,23 +513,11 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
provider_uid = provider_obj.uid
provider_type = provider_obj.provider
# Per-framework exporters in `COMPLIANCE_CLASS_MAP` consume the legacy bulk.
frameworks_bulk = Compliance.get_bulk(provider_type)
# Universal-only frameworks (top-level JSONs like `dora.json`) are emitted
# via `process_universal_compliance_frameworks` below.
universal_bulk = get_prowler_provider_compliance(provider_type)
universal_only_names = {
name
for name in universal_bulk
if name not in frameworks_bulk and universal_bulk[name].outputs
}
frameworks_avail = get_compliance_frameworks(provider_type)
out_dir, comp_dir = _generate_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
)
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
def get_writer(writer_map, name, factory, is_last):
"""
@@ -592,10 +535,6 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
output_writers = {}
compliance_writers = {}
# Shared across batches so universal writers are created once and reused.
universal_compliance_state: dict[str, list] = {"compliance": []}
universal_base_dir = os.path.dirname(out_dir)
universal_output_filename = os.path.basename(out_dir)
scan_summary = FindingOutput._transform_findings_stats(
ScanSummary.objects.filter(scan_id=scan_id)
@@ -650,30 +589,8 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
writer.batch_write_data_to_file(**extra)
writer._data.clear()
# Universal-only frameworks (e.g. `dora.json`).
if universal_only_names:
process_universal_compliance_frameworks(
input_compliance_frameworks=universal_only_names,
universal_frameworks=universal_bulk,
finding_outputs=fos,
output_directory=universal_base_dir,
output_filename=universal_output_filename,
provider=provider_type,
generated_outputs=universal_compliance_state,
from_cli=False,
is_last=is_last,
)
# Compliance CSVs (per-framework exporters).
# Compliance CSVs
for name in frameworks_avail:
if name in universal_only_names:
continue
if name not in frameworks_bulk:
logger.warning(
"Compliance framework '%s' missing from bulk; skipping CSV export",
name,
)
continue
compliance_obj = frameworks_bulk[name]
klass = GenericCompliance
@@ -749,7 +666,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
# TODO: We need to create a new periodic task to delete the output files
# This task shouldn't be responsible for deleting the output files
try:
rmtree(scan_tmp_dir, ignore_errors=True)
rmtree(Path(compressed).parent, ignore_errors=True)
except Exception as e:
logger.error(f"Error deleting output files: {e}")
final_location, did_upload = upload_uri, True
@@ -1160,13 +1077,10 @@ def security_hub_integration_task(
return upload_security_hub_integration(tenant_id, provider_id, scan_id)
# acks_late=False: Jira sends are not deduplicated and the task is not auto-recovered,
# so a crashed send is dropped rather than redelivered (avoids duplicate Jira issues).
@shared_task(
base=RLSTask,
name="integration-jira",
queue="integrations",
acks_late=False,
)
def jira_integration_task(
tenant_id: str,
@@ -1,408 +0,0 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from celery import states
from django.test import override_settings
from django_celery_results.models import TaskResult
from tasks.jobs.orphan_recovery import (
_decode_celery_field,
_reconcile_task_results,
_recovery_attempt_count,
advisory_lock,
is_worker_alive,
reconcile_orphans,
reenqueueable_tasks,
)
def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.STARTED):
"""Create a TaskResult mimicking an in-flight task, backdated past the grace."""
tr = TaskResult.objects.create(
task_id=str(uuid4()),
status=status,
task_name=name,
worker=worker,
task_kwargs=repr(kwargs),
task_args=repr([]),
)
TaskResult.objects.filter(pk=tr.pk).update(
date_created=datetime.now(tz=timezone.utc)
- timedelta(minutes=created_minutes_ago)
)
tr.refresh_from_db()
return tr
@pytest.mark.django_db
class TestDecodeCeleryField:
def test_decodes_single_encoded_repr(self):
assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
def test_decodes_double_encoded(self):
import json
stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"}))
assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"}
def test_empty_returns_default(self):
assert _decode_celery_field(None, {}) == {}
assert _decode_celery_field("", []) == []
def test_unparseable_raises(self):
with pytest.raises(ValueError):
_decode_celery_field("<<not a literal>>", {})
@pytest.mark.django_db
class TestReconcileTaskResults:
def _patches(self, alive):
"""Patch worker liveness, revoke, and the task registry for re-enqueue."""
mock_app = MagicMock()
mock_task = MagicMock()
mock_app.tasks.get.return_value = mock_task
return (
patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=alive),
patch("tasks.jobs.orphan_recovery.revoke_task"),
patch("tasks.jobs.orphan_recovery.current_app", mock_app),
mock_task,
)
def test_recovers_non_scan_task(self, tenants_fixture):
"""A NON-scan task (tenant-deletion) left orphaned is re-enqueued too."""
tenant = tenants_fixture[0]
tr = _orphan_result(
name="tenant-deletion",
kwargs={"tenant_id": str(tenant.id)},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["recovered"]
tr.refresh_from_db()
assert tr.status == states.REVOKED # stale result cleared (no pending alert)
mock_task.apply_async.assert_called_once()
call = mock_task.apply_async.call_args.kwargs
assert call["kwargs"] == {"tenant_id": str(tenant.id)}
assert call["task_id"] != tr.task_id # fresh task id
def test_external_integration_task_is_not_reenqueued_by_default(
self, tenants_fixture
):
"""External side-effect tasks without proven idempotency stay terminal.
integration-s3 rebuilds its upload from worker-local files that do not
survive the crash, so re-enqueuing it would upload nothing.
"""
tr = _orphan_result(
name="integration-s3",
kwargs={
"tenant_id": str(tenants_fixture[0].id),
"provider_id": str(uuid4()),
"output_directory": "/tmp/gone",
},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
@override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
def test_disabled_group_task_is_not_reenqueued(self, tenants_fixture):
"""A task whose group feature flag is off stays terminal, not re-enqueued."""
tr = _orphan_result(
name="scan-summary",
kwargs={
"tenant_id": str(tenants_fixture[0].id),
"scan_id": str(uuid4()),
},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
@override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
def test_disabled_group_task_does_not_consume_recovery_attempt(
self, tenants_fixture
):
"""A disabled-group task is failed without incrementing its Valkey attempt
counter, so re-enabling the group does not start it at the cap."""
tr = _orphan_result(
name="scan-summary",
kwargs={"tenant_id": str(tenants_fixture[0].id), "scan_id": str(uuid4())},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count") as mock_count,
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_count.assert_not_called()
def test_scan_task_is_skipped_entirely(self, tenants_fixture):
"""Scan tasks are excluded from recovery: the watchdog never touches them."""
tr = _orphan_result(
name="scan-perform",
kwargs={
"tenant_id": str(tenants_fixture[0].id),
"scan_id": str(uuid4()),
},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with p_alive, p_revoke, p_app:
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id not in result["recovered"]
assert tr.task_id not in result["failed"]
assert tr.task_id not in result["skipped"]
mock_task.apply_async.assert_not_called()
def test_jira_integration_task_is_not_reenqueued(self, tenants_fixture):
"""integration-jira stays terminal: re-running it would create duplicate Jira
issues, so an orphaned send is failed instead of re-enqueued."""
tenant = tenants_fixture[0]
kwargs = {
"tenant_id": str(tenant.id),
"integration_id": str(uuid4()),
"project_key": "PROWLER",
"issue_type": "Task",
"finding_ids": [str(uuid4()), str(uuid4())],
}
tr = _orphan_result(
name="integration-jira",
kwargs=kwargs,
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
tr.refresh_from_db()
assert tr.status == states.REVOKED # stale result cleared (no pending alert)
mock_task.apply_async.assert_not_called()
def test_skips_live_worker(self, tenants_fixture):
tr = _orphan_result(
name="tenant-deletion",
kwargs={"tenant_id": str(tenants_fixture[0].id)},
worker="alive@host",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=True)
with p_alive, p_revoke, p_app:
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["skipped"]
mock_task.apply_async.assert_not_called()
def test_skips_recently_created(self, tenants_fixture):
tr = _orphan_result(
name="tenant-deletion",
kwargs={"tenant_id": str(tenants_fixture[0].id)},
worker="dead@gone",
created_minutes_ago=0,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with p_alive, p_revoke, p_app:
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
# too recent: excluded by the grace window (not even a candidate)
assert tr.task_id not in result["recovered"]
mock_task.apply_async.assert_not_called()
def test_denylisted_task_failed_not_reenqueued(self, tenants_fixture):
"""A non-allowlisted task is failed, never blind re-run."""
tr = _orphan_result(
name="some-non-idempotent-task",
kwargs={"tenant_id": str(tenants_fixture[0].id)},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
tr.refresh_from_db()
assert tr.status == states.REVOKED
mock_task.apply_async.assert_not_called()
def test_recovery_cap_marks_failed(self, tenants_fixture):
"""When the recovery counter exceeds the cap, the task is failed not re-run."""
tr = _orphan_result(
name="tenant-deletion",
kwargs={"tenant_id": str(tenants_fixture[0].id)},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=4),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
@pytest.mark.django_db
class TestOrphanRecoveryHelpers:
def test_advisory_lock_acquires_and_releases(self):
with advisory_lock() as acquired:
assert acquired is True
def test_is_worker_alive_true_when_responds(self):
inspect = MagicMock()
inspect.ping.return_value = {"w@h": {"ok": "pong"}}
with patch(
"tasks.jobs.orphan_recovery.current_app.control.inspect",
return_value=inspect,
):
assert is_worker_alive("w@h") is True
def test_is_worker_alive_false_when_silent(self):
inspect = MagicMock()
inspect.ping.return_value = None
with patch(
"tasks.jobs.orphan_recovery.current_app.control.inspect",
return_value=inspect,
):
assert is_worker_alive("w@h") is False
def test_recovery_attempt_count_increments(self):
# Unique signature so the Valkey counter starts fresh for this test.
kwargs_repr = repr({"probe": str(uuid4())})
redis_client = MagicMock()
redis_client.incr.side_effect = [1, 2]
with patch("redis.from_url", return_value=redis_client):
assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 1
assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 2
class TestRecoveryFeatureFlags:
def test_all_groups_enabled_by_default(self):
tasks = reenqueueable_tasks()
assert "scan-summary" in tasks
assert {"provider-deletion", "tenant-deletion"} <= tasks
@override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
def test_summaries_group_flag_excludes_summary_tasks(self):
tasks = reenqueueable_tasks()
assert "scan-summary" not in tasks
assert "scan-compliance-overviews" not in tasks
assert "provider-deletion" in tasks
@override_settings(TASK_RECOVERY_DELETIONS_ENABLED=False)
def test_deletions_group_flag_excludes_deletion_tasks(self):
tasks = reenqueueable_tasks()
assert "provider-deletion" not in tasks
assert "tenant-deletion" not in tasks
assert "scan-summary" in tasks
@pytest.mark.django_db
class TestRecoveryMasterFlag:
@override_settings(TASK_RECOVERY_ENABLED=False)
def test_master_flag_disables_task_recovery(self):
with (
patch(
"tasks.jobs.orphan_recovery._reconcile_task_results"
) as mock_reconcile,
patch(
"tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
return_value={},
),
):
result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
mock_reconcile.assert_not_called()
assert result["acquired"] is True
assert result["enabled"] is False
@override_settings(TASK_RECOVERY_ENABLED=True)
def test_master_flag_enabled_runs_task_recovery(self):
with (
patch(
"tasks.jobs.orphan_recovery._reconcile_task_results",
return_value={"recovered": [], "failed": [], "skipped": []},
) as mock_reconcile,
patch(
"tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
return_value={},
),
):
reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
mock_reconcile.assert_called_once()
@@ -80,7 +80,7 @@ def basic_csa_compliance_data():
tenant_id="tenant-123",
scan_id="scan-456",
provider_id="provider-789",
compliance_id="csa_ccm_4.0",
compliance_id="csa_ccm_4.0_aws",
framework="CSA-CCM",
name="CSA Cloud Controls Matrix v4.0",
version="4.0",
-56
View File
@@ -1880,62 +1880,6 @@ class TestCreateComplianceRequirements:
assert "requirements_created" in result
@pytest.mark.django_db(transaction=True)
def test_create_compliance_requirements_idempotent_on_rerun(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
findings_fixture,
):
"""Re-running compliance materialization must not raise nor duplicate rows.
Uses transaction=True because the COPY path commits on its own connection,
so the test must use real commits (mirroring production) rather than the
default rollback wrapper.
"""
from api.models import ComplianceRequirementOverview
with patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template:
tenant_id = str(tenants_fixture[0].id)
scan_id = str(scans_fixture[0].id)
mock_compliance_template.__getitem__.return_value = {
"test_compliance": {
"framework": "Test Framework",
"version": "1.0",
"requirements": {
"req_1": {
"description": "Test Requirement 1",
"checks": {"test_check_id": None},
"checks_status": {
"pass": 2,
"fail": 1,
"manual": 0,
"total": 3,
},
"status": "FAIL",
},
},
}
}
create_compliance_requirements(tenant_id, scan_id)
count_after_first = ComplianceRequirementOverview.objects.filter(
scan_id=scan_id
).count()
# Second run must not raise and must not duplicate rows.
create_compliance_requirements(tenant_id, scan_id)
count_after_second = ComplianceRequirementOverview.objects.filter(
scan_id=scan_id
).count()
assert count_after_first > 0
assert count_after_second == count_after_first
def test_create_compliance_requirements_kubernetes_provider(
self,
tenants_fixture,
-77
View File
@@ -15,10 +15,8 @@ from tasks.jobs.lighthouse_providers import (
from tasks.tasks import (
DJANGO_TMP_OUTPUT_DIRECTORY,
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
ScanReportRLSTask,
_cleanup_orphan_scheduled_scans,
_perform_scan_complete_tasks,
_scan_tmp_output_directory,
check_integrations_task,
check_lighthouse_provider_connection_task,
generate_outputs_task,
@@ -323,7 +321,6 @@ class TestGenerateOutputs:
mock_transformed_stats = {"some": "stats"}
with (
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
patch(
"tasks.tasks.FindingOutput._transform_findings_stats",
return_value=mock_transformed_stats,
@@ -442,7 +439,6 @@ class TestGenerateOutputs:
mock_provider.uid = "test-provider-uid"
with (
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
patch("tasks.tasks.Provider.objects.get", return_value=mock_provider),
patch("tasks.tasks.initialize_prowler_provider"),
@@ -598,7 +594,6 @@ class TestGenerateOutputs:
]
with (
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
patch("tasks.tasks.ScanSummary.objects.filter") as mock_summary,
patch(
"tasks.tasks.Provider.objects.get",
@@ -673,7 +668,6 @@ class TestGenerateOutputs:
mock_provider.uid = "test-provider-uid"
with (
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
patch("tasks.tasks.Provider.objects.get", return_value=mock_provider),
patch("tasks.tasks.initialize_prowler_provider"),
@@ -777,38 +771,6 @@ class TestGenerateOutputs:
mock_s3_task.assert_called_once()
class TestScanReportRLSTaskOnFailure:
def test_on_failure_removes_scan_tmp_directory(self):
task = ScanReportRLSTask()
with patch("tasks.tasks.rmtree") as mock_rmtree:
task.on_failure(
exc=OSError("No space left on device"),
task_id="task-abc",
args=(),
kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
_einfo=None,
)
mock_rmtree.assert_called_once_with(
_scan_tmp_output_directory("t-1", "s-1"), ignore_errors=True
)
def test_on_failure_skips_when_missing_kwargs(self):
task = ScanReportRLSTask()
with patch("tasks.tasks.rmtree") as mock_rmtree:
task.on_failure(
exc=OSError("No space left on device"),
task_id="task-abc",
args=(),
kwargs={},
_einfo=None,
)
mock_rmtree.assert_not_called()
class TestScanCompleteTasks:
@patch("tasks.tasks.aggregate_attack_surface_task.apply_async")
@patch("tasks.tasks.chain")
@@ -1117,7 +1079,6 @@ class TestCheckIntegrationsTask:
enabled=True,
)
@patch("tasks.tasks.get_prowler_provider_compliance", return_value={})
@patch("tasks.tasks.s3_integration_task")
@patch("tasks.tasks.Integration.objects.filter")
@patch("tasks.tasks.ScanSummary.objects.filter")
@@ -1150,7 +1111,6 @@ class TestCheckIntegrationsTask:
mock_scan_summary,
mock_integration_filter,
mock_s3_task,
mock_get_prowler_compliance,
):
"""Test that ASFF output is generated for AWS providers with SecurityHub integration."""
# Setup
@@ -1247,7 +1207,6 @@ class TestCheckIntegrationsTask:
assert result == {"upload": True}
@patch("tasks.tasks.get_prowler_provider_compliance", return_value={})
@patch("tasks.tasks.s3_integration_task")
@patch("tasks.tasks.Integration.objects.filter")
@patch("tasks.tasks.ScanSummary.objects.filter")
@@ -1280,7 +1239,6 @@ class TestCheckIntegrationsTask:
mock_scan_summary,
mock_integration_filter,
mock_s3_task,
mock_get_prowler_compliance,
):
"""Test that ASFF output is NOT generated for AWS providers without SecurityHub integration."""
# Setup
@@ -1374,7 +1332,6 @@ class TestCheckIntegrationsTask:
assert result == {"upload": True}
@patch("tasks.tasks.get_prowler_provider_compliance", return_value={})
@patch("tasks.tasks.ScanSummary.objects.filter")
@patch("tasks.tasks.Provider.objects.get")
@patch("tasks.tasks.initialize_prowler_provider")
@@ -1403,7 +1360,6 @@ class TestCheckIntegrationsTask:
mock_initialize_provider,
mock_provider_get,
mock_scan_summary,
mock_get_prowler_compliance,
):
"""Test that ASFF output is NOT generated for non-AWS providers (e.g., Azure, GCP)."""
# Setup
@@ -2716,36 +2672,3 @@ class TestReaggregateAllFindingGroupSummaries:
assert result == {"scans_reaggregated": 0}
mock_group.assert_not_called()
mock_chain.assert_not_called()
class TestTaskTimeLimits:
"""The per-task limits in task_annotations must actually take effect.
Celery applies a "*" annotation after the per-task one, so a "*" entry would
silently overwrite every specific limit and cap long scans at the default. The
default is set as the global limit instead, and these per-task limits must win.
"""
def test_long_running_tasks_exceed_the_default_limit(self):
from config.celery import celery_app
default = celery_app.conf.task_time_limit
for name in (
"scan-perform",
"scan-perform-scheduled",
"provider-deletion",
"tenant-deletion",
):
assert celery_app.tasks[name].time_limit > default
def test_connection_checks_stay_below_the_default_limit(self):
from config.celery import celery_app
default = celery_app.conf.task_time_limit
for name in (
"provider-connection-check",
"integration-connection-check",
"lighthouse-connection-check",
"lighthouse-provider-connection-check",
):
assert celery_app.tasks[name].time_limit < default
Generated
+23 -28
View File
@@ -163,7 +163,7 @@ constraints = [
{ name = "drf-simple-apikey", specifier = "==2.2.1" },
{ name = "drf-spectacular", specifier = "==0.27.2" },
{ name = "drf-spectacular-jsonapi", specifier = "==0.5.1" },
{ name = "dulwich", specifier = "==1.2.5" },
{ name = "dulwich", specifier = "==0.23.0" },
{ name = "duo-client", specifier = "==5.5.0" },
{ name = "durationpy", specifier = "==0.10" },
{ name = "email-validator", specifier = "==2.2.0" },
@@ -291,7 +291,7 @@ constraints = [
{ name = "pydantic-core", specifier = "==2.41.5" },
{ name = "pygithub", specifier = "==2.8.0" },
{ name = "pygments", specifier = "==2.20.0" },
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pyjwt", specifier = "==2.12.1" },
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
@@ -374,10 +374,8 @@ constraints = [
{ name = "zstd", specifier = "==1.5.7.3" },
]
overrides = [
{ name = "dulwich", specifier = "==1.2.5" },
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
]
[[package]]
@@ -395,7 +393,7 @@ version = "1.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyjwt" },
{ name = "python-dateutil" },
{ name = "requests" },
]
@@ -1076,7 +1074,7 @@ dependencies = [
{ name = "pkginfo" },
{ name = "psutil", marker = "sys_platform != 'cygwin'" },
{ name = "py-deviceid" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyjwt" },
{ name = "pyopenssl" },
{ name = "requests", extra = ["socks"] },
]
@@ -2459,7 +2457,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
wheels = [
@@ -2578,27 +2576,24 @@ wheels = [
[[package]]
name = "dulwich"
version = "1.2.5"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/ac/ba58cf420640c7bc77ae8e1b31e174d83c9117750c63cf9ea3b5e202e5c4/dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae", size = 575116, upload-time = "2025-06-21T17:56:47.494Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" },
{ url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" },
{ url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" },
{ url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" },
{ url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" },
{ url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" },
{ url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" },
{ url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" },
{ url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" },
{ url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" },
{ url = "https://files.pythonhosted.org/packages/ae/11/f6bbba8583f69cf19ef4bd7f5fde1a6b5ccaf8b6951781cec8db247116f4/dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc", size = 972658, upload-time = "2025-06-21T17:56:13.505Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9d/2720e0ab58666378a33c752a61543f936cd6b06dfe5d84a2215ddc0914b0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527", size = 1049813, upload-time = "2025-06-21T17:56:14.884Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f3/81d8075141dfcc0a0449c2093596e58d3e11444e3af54e819eca63b84dd0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261", size = 1051639, upload-time = "2025-06-21T17:56:16.437Z" },
{ url = "https://files.pythonhosted.org/packages/4f/0d/c06ccb227b096aef5906142fe78b5c79f9070a0ea6152fc219941186d540/dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a", size = 642918, upload-time = "2025-06-21T17:56:18.373Z" },
{ url = "https://files.pythonhosted.org/packages/d7/1c/1e99aa34c9aead9e641b2d9934f0a3d00257f75027cf5cdecc8a1a6c18ae/dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd", size = 659010, upload-time = "2025-06-21T17:56:19.947Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d7/1e6fba0235babe912e8467b036062e37d11672cbbeb0d8074f9d4559057b/dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e", size = 960292, upload-time = "2025-06-21T17:56:21.308Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6a/23f0c487ec03f2752600cab4a8e0dedb38186246c475bf3fa90a8db830d5/dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e", size = 1047892, upload-time = "2025-06-21T17:56:22.989Z" },
{ url = "https://files.pythonhosted.org/packages/c7/e2/8f3d216be5fd0ee1180d917b59b34b54b9896384cf139f319b5d3a8f16b4/dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9", size = 1048699, upload-time = "2025-06-21T17:56:24.602Z" },
{ url = "https://files.pythonhosted.org/packages/8f/c4/18e6223cd4ad1ae9334eb4e6aa5952fd8f5c3d75762918eb90c209fec4ba/dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf", size = 641268, upload-time = "2025-06-21T17:56:26.18Z" },
{ url = "https://files.pythonhosted.org/packages/b8/9c/65bfbbac62d8a2967e13f6a1512371c5eb6b906a61fb6dead992669cad0e/dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59", size = 657837, upload-time = "2025-06-21T17:56:27.821Z" },
{ url = "https://files.pythonhosted.org/packages/35/31/49318ee9db4b402e6d8b9b01bd4cae9298f59e1bb9bd56cf4a94e48fa069/dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135", size = 313776, upload-time = "2025-06-21T17:56:46.221Z" },
]
[[package]]
@@ -4036,7 +4031,7 @@ dependencies = [
{ name = "pycryptodomex" },
{ name = "pydantic" },
{ name = "pydash" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyjwt" },
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "requests" },
@@ -4878,11 +4873,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.13.0"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
@@ -5790,7 +5785,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "httpx" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/2f/99fb8718274116c5c146c745755620fd5c5943f78ca52ca9b17e94348286/workos-6.0.4.tar.gz", hash = "sha256:b0bfe8fd212b8567422c4ea3732eb33608794033eb3a69900c6b04db183c32d6", size = 172217, upload-time = "2026-04-16T03:09:28.583Z" }
wheels = [
@@ -1,27 +0,0 @@
import warnings
from dashboard.common_methods import get_section_containers_3_levels
warnings.filterwarnings("ignore")
def get_table(data):
aux = data[
[
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"NAME",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
]
return get_section_containers_3_levels(
aux,
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"NAME",
)
-2
View File
@@ -139,8 +139,6 @@ services:
worker-dev:
image: prowler-api-dev
# Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop.
stop_grace_period: 120s
build:
context: ./api
dockerfile: Dockerfile
-2
View File
@@ -129,8 +129,6 @@ services:
worker:
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
# Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop.
stop_grace_period: 120s
env_file:
- path: .env
required: false
+36 -75
View File
@@ -8,77 +8,7 @@ This guide explains the AI Skills system that provides on-demand context and pat
**What are AI Skills?** Skills are structured instructions that help AI agents (Claude Code, Cursor, Copilot, etc.) understand Prowler's conventions, patterns, and best practices.
</Info>
Skills live in the [`skills/`](https://github.com/prowler-cloud/prowler/tree/master/skills) directory of the Prowler OSS repository. Each skill is a folder containing a `SKILL.md` file with its patterns and metadata.
## Installation
To enable skills for the supported AI coding assistants, run the setup script from the repository root:
```bash
./skills/setup.sh
```
The script creates symlinks so each tool finds the skills in its expected location:
| Tool | Created by setup |
|------|------------------|
| Claude Code | `.claude/skills/` symlink and `CLAUDE.md` |
| Gemini CLI | `.gemini/skills/` symlink and `GEMINI.md` |
| Codex (OpenAI) | `.codex/skills/` symlink (uses `AGENTS.md` natively) |
| GitHub Copilot | `.github/copilot-instructions.md` symlink to `AGENTS.md` |
After running the setup, restart the AI coding assistant to load the skills.
## Using Skills
AI agents discover skills automatically and load them when a request matches a skill trigger. To load a skill manually during a session, point the agent to the skill's `SKILL.md` file:
```text
Read skills/{skill-name}/SKILL.md
```
For the full list of available skills, their triggers, and the Auto-invoke mappings, see the [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) and [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) in the repository.
## Available Skills
| Type | Skills |
|------|--------|
| **Generic** | typescript, react-19, nextjs-16, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5, vitest, tdd |
| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci, prowler-attack-paths-query |
| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui |
| **Meta** | skill-creator, skill-sync |
<Note>
This table is a snapshot. The repository is the source of truth: see [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) for the current, complete list.
</Note>
## Skill Structure
Each skill follows the [Agent Skills spec](https://agentskills.io):
```text
skills/{skill-name}/
├── SKILL.md # Patterns, rules, decision trees
├── assets/ # Code templates, schemas
└── references/ # Links to local docs (single source of truth)
```
## Key Design Decisions
1. **Self-contained skills** - Critical patterns inline for fast loading
2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx`
3. **Single source of truth** - Skills reference docs, no duplication
4. **On-demand loading** - AI loads only what's needed for the task
## Creating New Skills
Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) for the full list of available skills and their triggers.
## How Skills Work
The diagrams below explain the internals of the skill system. They are useful for understanding the design, but are not required to install or use skills.
### Architecture Overview
## Architecture Overview
```mermaid
graph LR
@@ -98,7 +28,7 @@ graph LR
style F fill:#1a4d2e,stroke:#66bb6a,color:#fff
```
### Request Lifecycle
## How It Works
```mermaid
sequenceDiagram
@@ -138,7 +68,7 @@ sequenceDiagram
A->>U: Creates check with correct patterns
```
### With and Without Skills
## Before vs After
```mermaid
graph TD
@@ -166,7 +96,7 @@ graph TD
style AFTER fill:#1a4d1a,stroke:#66bb6a,color:#fff
```
### Full Component Map
## Complete Architecture
```mermaid
flowchart TB
@@ -180,7 +110,7 @@ flowchart TB
subgraph GENERIC["Generic Skills"]
G1["typescript"]
G2["react-19"]
G3["nextjs-16"]
G3["nextjs-15"]
G4["tailwind-4"]
G5["pytest"]
G6["playwright"]
@@ -256,3 +186,34 @@ flowchart TB
style STRUCTURE fill:#5c3d1a,stroke:#ffb74d,color:#fff
style DOCS fill:#1a3d4d,stroke:#4dd0e1,color:#fff
```
## Skills Included
| Type | Skills |
|------|--------|
| **Generic** | typescript, react-19, nextjs-15, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5 |
| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci |
| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui |
| **Meta** | skill-creator, skill-sync |
## Skill Structure
Each skill follows the [Agent Skills spec](https://agentskills.io):
```
skills/{skill-name}/
├── SKILL.md # Patterns, rules, decision trees
├── assets/ # Code templates, schemas
└── references/ # Links to local docs (single source of truth)
```
## Key Design Decisions
1. **Self-contained skills** - Critical patterns inline for fast loading
2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx`
3. **Single source of truth** - Skills reference docs, no duplication
4. **On-demand loading** - AI loads only what's needed for the task
## Creating New Skills
Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See `AGENTS.md` for the full list of available skills and their triggers.
@@ -2,228 +2,40 @@
title: 'Creating a New Security Compliance Framework in Prowler'
---
This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the two supported JSON schemas (universal and legacy), the Pydantic models that validate each framework, check mapping conventions, output formatting, local validation, testing, and the pull request process.
This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the JSON schema, check mapping conventions, the Pydantic models that validate each framework, the CSV output formatter, local validation, testing, and the pull request process.
## Introduction
A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC, DORA) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud.
A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud.
Prowler ships 85+ compliance frameworks across all providers. The catalog lives under `prowler/compliance/<provider>/` (legacy, per-provider) or `prowler/compliance/` (universal, multi-provider).
Prowler ships with 85+ compliance frameworks across All Providers. The catalog lives under `prowler/compliance/<provider>/` (or `prowler/compliance/` for universal compliance frameworks)
<Warning>
A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when no Prowler check can automate it. In that case, leave the requirement's check list empty, but do not omit the requirement.
A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when none of the existing Prowler checks can automate it. In that case, leave `Checks` as an empty array, but do not omit the requirement.
Requirement coverage feeds the compliance percentage calculations and the metadata surfaces (dashboards, widgets, exports). Missing requirements skew those metrics and break the report as a faithful snapshot of the framework.
</Warning>
### Two supported schemas
| Schema | When to use | File location | Discovered as |
| --- | --- | --- | --- |
| **Universal (recommended for new frameworks)** | Multi-provider frameworks, or single-provider frameworks that benefit from declarative table/PDF rendering | `prowler/compliance/<framework>.json` (top-level) | Available for **every** provider whose key appears in any `requirement.checks` dict |
| **Legacy provider-specific** | Single-provider frameworks with framework-specific attribute classes already declared in the codebase (CIS, ENS, ISO 27001, etc.) | `prowler/compliance/<provider>/<framework>_<version>_<provider>.json` | Available only under that provider |
Auto-discovery happens in `get_bulk_compliance_frameworks_universal(provider)` (`prowler/lib/check/compliance_models.py:915`), which scans **both** the top-level `prowler/compliance/` directory and every per-provider sub-directory. Legacy frameworks are transparently converted to the universal `ComplianceFramework` model via `adapt_legacy_to_universal()` before being returned, so the rest of Prowler — CLI table rendering, CSV/OCSF outputs, PDF generation — works the same regardless of the source schema.
> The legacy entry-point `Compliance.get_bulk(provider)` (used by older code paths) only scans per-provider sub-directories. Universal top-level files are picked up exclusively via the universal loader; this matters if you are wiring a new code path against the legacy API.
For **new** frameworks, prefer the universal schema: it requires no Python code changes, supports multiple providers in a single file, and table/PDF rendering is driven entirely from declarative configuration inside the JSON.
> All Pydantic models in `compliance_models.py` are imported from `pydantic.v1`. Subclasses you add for the legacy schema must use `from pydantic.v1 import BaseModel`.
### Prerequisites
Before adding a new framework, complete the following checks:
- **Verify the framework is not already supported.** Inspect `prowler/compliance/` and every `prowler/compliance/<provider>/` for an existing JSON file matching the name and version.
- **Verify the framework is not already supported.** Inspect `prowler/compliance/<provider>/` for an existing JSON file matching the name and version.
- **Confirm the required checks exist.** Every requirement that can be automated must point to one or more existing Prowler checks. For each missing check, implement it first by following the [Prowler Checks](/developer-guide/checks) guide.
- **Review a reference framework.** Use an existing framework with a similar structure as your template:
- Universal: `prowler/compliance/dora.json`, `prowler/compliance/csa_ccm_4.0.json`.
- Legacy: `prowler/compliance/aws/cis_2.0_aws.json` (canonical CIS shape), `prowler/compliance/aws/ccc_aws.json`, `prowler/compliance/aws/ens_rd2022_aws.json`, `prowler/compliance/aws/nist_800_53_revision_5_aws.json`.
- **Review a reference framework.** Use an existing framework with a similar structure as your template. `cis_2.0_aws.json` is the canonical reference for CIS-style frameworks. `ccc_aws.json`, `ens_rd2022_aws.json`, and `nist_800_53_revision_5_aws.json` illustrate other attribute shapes.
## Universal Compliance Framework
## Four-Layer Architecture
### Where the file lives
A compliance framework spans four layers. A complete contribution must touch each layer that applies.
Place the file at the top level of the compliance directory:
- **Layer 1 Schema validation:** The Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape (CIS, ENS, Mitre, CCC, C5, CSA CCM, ISO 27001, KISA ISMS-P, AWS Well-Architected, Prowler ThreatScore, and a generic fallback).
- **Layer 2 JSON catalog:** The framework JSON file in `prowler/compliance/<provider>/` lists every requirement and maps it to checks.
- **Layer 3 Output formatter:** The Python module in `prowler/lib/outputs/compliance/<framework>/` builds the CSV row model, the per-provider transformer, and the CLI summary table.
- **Layer 4 Output dispatchers:** The dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier.
```
prowler/compliance/<framework_name>.json
```
The rest of this guide walks each layer in order.
Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora.json`.
The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora.json` → `dora`).
### Top-level structure
```json
{
"framework": "<short identifier, e.g. \"DORA\" or \"CSA-CCM\">",
"name": "<human-readable full name>",
"version": "<framework version>",
"description": "<one-paragraph description shown in --list-compliance and PDF reports>",
"icon": "<short icon slug, optional>",
"attributes_metadata": [ /* see below */ ],
"outputs": { /* see below — optional */ },
"requirements": [ /* see below */ ]
}
```
A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py:739`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring.
Provider keys inside `requirement.checks` must match the directory names under `prowler/providers/`. The valid keys at present are: `aws`, `azure`, `gcp`, `m365`, `kubernetes`, `iac`, `github`, `googleworkspace`, `alibabacloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `oraclecloud`, `llm`. Comparison in `supports_provider()` is case-insensitive, but lowercase is the convention used everywhere in the repository.
### `attributes_metadata`
Declares the shape of the per-requirement `attributes` dict. When this field is present, the root validator `validate_attributes_against_metadata` (`compliance_models.py:669`) enforces the schema at load time and rejects:
- Missing keys marked `required: true`.
- Keys present in `attributes` but not declared in `attributes_metadata` (typo / drift guard).
- Values that violate a declared `enum`.
- Values whose Python type does not match a declared `int`, `float` or `bool`.
The runtime type check **only** covers `int`, `float` and `bool`. For `str`, `list_str` and `list_dict` the type is documentation-only — non-conforming values won't fail validation. If `attributes_metadata` is omitted, **no per-requirement validation runs at all**.
```json
"attributes_metadata": [
{
"key": "Pillar",
"label": "Pillar",
"type": "str",
"required": true,
"enum": [
"ICT Risk Management",
"ICT-Related Incident Reporting",
"Digital Operational Resilience Testing",
"ICT Third-Party Risk Management",
"Information Sharing"
],
"output_formats": { "csv": true, "ocsf": true }
},
{
"key": "Article",
"label": "Article",
"type": "str",
"required": true,
"output_formats": { "csv": true, "ocsf": true }
}
]
```
Per attribute:
- `key` (required): attribute name as it will appear in `requirement.attributes`.
- `label`: human-readable label used in CSV headers and PDF.
- `type`: one of `str`, `int`, `float`, `bool`, `list_str`, `list_dict`. Defaults to `str`.
- `enum`: optional list of allowed values; non-conforming values are rejected at load time.
- `required`: if `true`, every requirement must include this key with a non-null value.
- `enum_display` / `enum_order`: optional per-enum-value visual metadata (label, abbreviation, color, icon) and explicit ordering for PDF rendering.
- `output_formats`: `{ "csv": <bool>, "ocsf": <bool> }` — toggles inclusion in each output format. Both default to `true`.
### `outputs`
Optional. Controls how the framework is rendered in the console table and in the generated PDF report. Skipping it falls back to sensible defaults.
```json
"outputs": {
"table_config": {
"group_by": "Pillar"
},
"pdf_config": {
"language": "en",
"primary_color": "#003399",
"secondary_color": "#0055A5",
"bg_color": "#F0F4FA",
"group_by_field": "Pillar",
"sections": [ "ICT Risk Management", "ICT-Related Incident Reporting", "..." ],
"section_short_names": { "ICT Risk Management": "ICT Risk Mgmt" },
"charts": [
{
"id": "pillar_compliance",
"type": "horizontal_bar",
"group_by": "Pillar",
"title": "Compliance Score by Pillar",
"y_label": "Pillar",
"x_label": "Compliance %",
"value_source": "compliance_percent",
"color_mode": "by_value"
}
],
"filter": { "only_failed": true, "include_manual": false }
}
}
```
`table_config.group_by` must reference an attribute key declared in `attributes_metadata`. The same applies to `pdf_config.group_by_field` and to every `charts[].group_by`.
For frameworks with weighted scoring (e.g. ThreatScore) declare `pdf_config.scoring` with `risk_field` / `weight_field` / `risk_boost_factor`. For column splitting (e.g. CIS Level 1 vs Level 2) use `table_config.split_by`.
### `requirements`
```json
"requirements": [
{
"id": "DORA-Art5",
"name": "Governance and organisation",
"description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. ...",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 5",
"ArticleTitle": "Governance and organisation"
},
"checks": {
"aws": [
"iam_avoid_root_usage",
"iam_no_root_access_key",
"iam_root_mfa_enabled"
],
"azure": [],
"gcp": []
}
}
]
```
Per requirement:
- `id` (required): unique identifier within the framework.
- `description` (required): the requirement text as authored by the framework.
- `name`: short title shown alongside the id.
- `attributes`: flat dict; keys must conform to `attributes_metadata`.
- `checks`: dict keyed by provider name (the same lowercase keys listed in the previous section). Each value is a list of Prowler check names that evidence this requirement for that provider. The list **may be empty** and the dict itself defaults to `{}` if omitted; either way the requirement is still loaded and listed by `--list-compliance-requirements`, it just has zero checks to execute. Note: there is **no automatic check-existence validation** at load time — referencing a non-existent check name will silently produce a requirement with no findings. Validate this yourself (see "Validating Your Framework" below).
For MITRE-style frameworks, additional optional fields are available on the requirement: `tactics`, `sub_techniques`, `platforms`, `technique_url` (these are populated automatically when adapting a legacy MITRE JSON to the universal model).
### Multi-provider frameworks
A single universal file can cover any number of providers. The framework appears under each provider's `--list-compliance` output as long as **at least one** requirement has that provider key in its `checks` dict.
When extending an existing universal framework with a new provider, the only change required is editing `requirement.checks`:
```diff
"checks": {
"aws": ["iam_avoid_root_usage", "iam_no_root_access_key"],
+ "azure": ["entra_policy_ensure_mfa_for_admin_roles"]
}
```
No code changes, no new file, no registration step.
## Legacy Provider-Specific Compliance Framework
The legacy schema is still fully supported and remains the format used by most frameworks shipped today (CIS, NIST, ISO 27001, FedRAMP, PCI DSS, GDPR, HIPAA, ENS, etc.). It binds a framework to a single provider and validates each requirement against a framework-specific Pydantic attribute class.
The legacy schema spans **four layers** — a complete contribution must touch every layer that applies:
- **Layer 1 — Schema validation:** the Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape.
- **Layer 2 — JSON catalog:** the framework JSON file in `prowler/compliance/<provider>/` lists every requirement and maps it to checks.
- **Layer 3 — Output formatter:** the Python module in `prowler/lib/outputs/compliance/<framework>/` builds the CSV row model, the per-provider transformer, and the CLI summary table.
- **Layer 4 — Output dispatchers:** the dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier.
The universal schema collapses Layers 3 and 4 into declarative configuration inside the JSON — that is the main reason it is preferred for new contributions.
### Directory structure and file naming
## Directory Structure and File Naming
Compliance frameworks live at:
@@ -234,8 +46,8 @@ prowler/compliance/<provider>/<framework>_<version>_<provider>.json
The filename conventions are:
- All lowercase, words separated with underscores.
- `<provider>` is a supported provider identifier (same lowercase list as the universal section above).
- `<version>` is optional but recommended. Omit only when the framework has no versioning (e.g. `ccc_aws.json`).
- `<provider>` is a supported provider identifier: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `googleworkspace`, `alibabacloud`, `oraclecloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `iac`, `llm`.
- `<version>` is optional. Omit it when the framework has no versioning, as in `ccc_aws.json`.
- The file basename (without `.json`) is the framework key that Prowler CLI accepts via `--compliance`.
Examples:
@@ -250,50 +62,48 @@ The output formatter directory mirrors the framework name:
```
prowler/lib/outputs/compliance/<framework>/
├── <framework>.py # CLI summary-table dispatcher
├── <framework>.py # CLI summary-table dispatcher
├── <framework>_<provider>.py # Per-provider transformer class
├── models.py # Pydantic CSV row model
└── __init__.py
```
### JSON schema reference
## JSON Schema Reference
Every legacy compliance file is a JSON document with the following top-level keys. `Framework`, `Name` and `Provider` are validated non-empty by the root validator `framework_and_provider_must_not_be_empty` (`compliance_models.py:329`).
Every compliance file is a JSON document with the following top-level keys.
| Field | Type | Required | Description |
|---|---|---|---|
| `Framework` | string | Yes | Canonical framework identifier, for example `CIS`, `NIST-800-53-Revision-5`, `ENS`, `CCC`. |
| `Name` | string | Yes | Human-readable framework name displayed by Prowler App. |
| `Version` | string | Yes (recommended) | Framework version, e.g. `2.0`. See [Version Handling](#version-handling). |
| `Version` | string | Yes | Framework version, for example `2.0`. Use an empty string only for frameworks without versioning. See [Version Handling](#version-handling). |
| `Provider` | string | Yes | Upper-cased provider identifier: `AWS`, `AZURE`, `GCP`, `KUBERNETES`, `M365`, `GITHUB`, `GOOGLEWORKSPACE`, and so on. |
| `Description` | string | Yes | Short description of the framework's scope and purpose. |
| `Requirements` | array | Yes | List of [requirement objects](#requirement-object). |
#### Requirement Object
### Requirement Object
Each entry in `Requirements` describes one control or requirement.
| Field | Type | Required | Description |
|---|---|---|---|
| `Id` | string | Yes | Unique identifier within the framework, for example `1.10` or `CCC.Core.CN01.AR01`. |
| `Name` | string | No | Optional human-readable name (frameworks like NIST distinguish control name from description). |
| `Name` | string | No | Optional human-readable name used by frameworks that distinguish control name from description, such as NIST. |
| `Description` | string | Yes | Verbatim description from the source framework. |
| `Attributes` | array | Yes | List of [attribute objects](#attribute-objects). The shape depends on the framework. |
| `Checks` | array of strings | Yes | Prowler check identifiers that automate the requirement. Leave the list empty when the control cannot be automated. |
#### Attribute Objects
### Attribute Objects
`Attributes` is parsed against the union declared in `Compliance_Requirement.Attributes` (`compliance_models.py:293`). Pydantic v1 tries each member of the union in declaration order and falls back to `Generic_Compliance_Requirement_Attribute` (the last entry) when nothing else matches — so a brand-new shape that doesn't match any existing class will silently be accepted as Generic, losing its specific fields.
Attributes carry the metadata that Prowler App and the CSV output display for each requirement. The object shape is framework-specific and is validated by a dedicated Pydantic model in `prowler/lib/check/compliance_models.py`. The most common shapes are summarized below.
As of today, the registered attribute classes are: `CIS_Requirement_Attribute`, `ENS_Requirement_Attribute`, `ASDEssentialEight_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `AWS_Well_Architected_Requirement_Attribute`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `CCC_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`, and `Generic_Compliance_Requirement_Attribute` (fallback). MITRE-style frameworks use the separate `Mitre_Requirement` model with `Tactics` / `SubTechniques` / `Platforms` / `TechniqueURL` at the requirement top level. The most common shapes are summarized below.
##### CIS_Requirement_Attribute
#### CIS_Requirement_Attribute
Used by every CIS benchmark.
| Field | Type | Required | Notes |
|---|---|---|---|
| `Section` | string | Yes | Top-level section, e.g. `1 Identity and Access Management`. |
| `Section` | string | Yes | Top-level section, for example `1 Identity and Access Management`. |
| `SubSection` | string | No | Optional second-level grouping. |
| `Profile` | enum | Yes | One of `Level 1`, `Level 2`, `E3 Level 1`, `E3 Level 2`, `E5 Level 1`, `E5 Level 2`. |
| `AssessmentStatus` | enum | Yes | `Manual` or `Automated`. |
@@ -306,7 +116,7 @@ Used by every CIS benchmark.
| `DefaultValue` | string | No | Default configuration value, when relevant. |
| `References` | string | Yes | Colon-separated list of reference URLs. |
##### ENS_Requirement_Attribute
#### ENS_Requirement_Attribute
Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks.
@@ -322,13 +132,13 @@ Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks.
| `ModoEjecucion` | string | Yes | Execution mode (`manual`, `automático`, `híbrido`). |
| `Dependencias` | array of strings | Yes | Ids of prerequisite controls. Empty list when none. |
##### CCC_Requirement_Attribute
#### CCC_Requirement_Attribute
Used by the Common Cloud Controls Catalog.
| Field | Type | Required | Notes |
|---|---|---|---|
| `FamilyName` | string | Yes | Control family, e.g. `Data`. |
| `FamilyName` | string | Yes | Control family, for example `Data`. |
| `FamilyDescription` | string | Yes | Description of the family. |
| `Section` | string | Yes | Section title. |
| `SubSection` | string | Yes | Subsection title, or empty string. |
@@ -338,9 +148,9 @@ Used by the Common Cloud Controls Catalog.
| `SectionThreatMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. |
| `SectionGuidelineMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. |
##### Generic_Compliance_Requirement_Attribute
#### Generic_Compliance_Requirement_Attribute
The fallback attribute model used when no framework-specific schema applies (e.g. NIST 800-53, PCI DSS, GDPR, HIPAA). It is **always the last** element of the `Compliance_Requirement.Attributes` Union; that ordering is load-bearing.
The fallback attribute model used when no framework-specific schema applies (for example NIST 800-53, PCI DSS, GDPR, HIPAA).
| Field | Type | Required | Notes |
|---|---|---|---|
@@ -348,17 +158,17 @@ The fallback attribute model used when no framework-specific schema applies (e.g
| `Section` | string | No | Section name. |
| `SubSection` | string | No | Subsection name. |
| `SubGroup` | string | No | Subgroup name. |
| `Service` | string | No | Affected service, e.g. `iam`. |
| `Service` | string | No | Affected service, for example `aws`, `iam`. |
| `Type` | string | No | Control type. |
| `Comment` | string | No | Free-form comment. |
For the remaining attribute classes (`AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_<Provider>`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`) consult `prowler/lib/check/compliance_models.py` for the full field sets.
Additional per-framework attribute models exist for `AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_<Provider>`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, and `CSA_CCM_Requirement_Attribute`. Consult `prowler/lib/check/compliance_models.py` for their full field sets.
<Note>
The `Attributes` field is a Pydantic `Union`. The generic attribute model **must** remain the last element of that Union otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped. Adding a brand-new attribute shape requires inserting the Pydantic class **before** `Generic_Compliance_Requirement_Attribute`.
The `Attributes` field is a Pydantic `Union`. The generic attribute model must remain the last element of that Union, otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped.
</Note>
#### Minimal working example
## Minimal Working Example
The following snippet is a complete, valid framework file named `my_framework_1.0_aws.json`, saved at `prowler/compliance/aws/my_framework_1.0_aws.json`. It uses the generic attribute shape for simplicity.
@@ -404,26 +214,26 @@ The following snippet is a complete, valid framework file named `my_framework_1.
}
```
### Mapping checks to requirements
## Mapping Checks to Requirements
Each requirement links to the Prowler checks that, together, produce a PASS or FAIL verdict for that control.
- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count.
- List every check by its canonical identifier the value of `CheckID` inside the check's `.metadata.json` file.
- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count, so omitting an unmappable control inflates coverage and misrepresents the framework.
- List every check by its canonical identifier, the value of `CheckID` inside the check's `.metadata.json` file.
- One requirement can reference multiple checks. The requirement is evaluated as FAIL when any referenced check produces a FAIL finding for a resource in scope.
- Leave `Checks` (legacy) or `checks.<provider>` (universal) as an empty array when the requirement cannot be automated. The requirement still appears in the report and contributes to the total.
- Leave `Checks` as an empty array when the requirement cannot be automated. The requirement still appears in the report, contributes to the total, and resolves to `MANUAL`. An empty mapping is valid; a missing requirement is not.
- Reuse checks across requirements when the same control applies in multiple places. Do not duplicate check logic to match framework structure.
- Avoid referencing checks from a different provider. A legacy compliance file is bound to one provider, and cross-provider checks will never match findings in the scan.
- Avoid referencing checks from a different provider. A compliance file is bound to one provider, and cross-provider checks will never match findings in the scan.
To discover available checks:
To discover available checks, run:
```bash
uv run python prowler-cli.py <provider> --list-checks
```
### Supporting multiple providers (legacy)
## Supporting Multiple Providers
The legacy schema binds each file to a single provider. To cover several providers with the same framework, ship one JSON file per provider:
Each compliance file targets a single provider. To cover several providers with the same framework (for example CIS across AWS, Azure, and GCP), ship one JSON file per provider:
```
prowler/compliance/aws/cis_2.0_aws.json
@@ -431,15 +241,15 @@ prowler/compliance/azure/cis_2.0_azure.json
prowler/compliance/gcp/cis_2.0_gcp.json
```
Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them; change only the `Provider`, `Checks`, and provider-specific metadata. The CIS output formatter already supports every provider listed above.
Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them, and change only the `Provider`, `Checks`, and provider-specific metadata.
For a brand-new framework that spans several providers, **prefer the universal schema** — it covers every provider from a single file. If you must use the legacy schema, add one transformer per provider in `prowler/lib/outputs/compliance/<framework>/` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter).
The CIS output formatter already supports every provider listed above. For a brand-new framework that spans several providers, add one transformer per provider in `prowler/lib/outputs/compliance/<framework>/` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter).
### Output formatter
## Output Formatter
Legacy frameworks render in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework. Universal frameworks do **not** need a Python output formatter — the `outputs` config inside the JSON drives rendering — so this section applies only to the legacy schema.
Prowler renders every compliance framework in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework.
For a new legacy framework named `my_framework`, create:
For a new framework named `my_framework`, create:
```
prowler/lib/outputs/compliance/my_framework/
@@ -449,19 +259,19 @@ prowler/lib/outputs/compliance/my_framework/
└── models.py # CSV row Pydantic model
```
#### Step 1 Define the CSV row model
### Step 1 Define the CSV Row Model
In `models.py`, declare a Pydantic v1 model with one field per CSV column. Use existing models such as `AWSCISModel` in `prowler/lib/outputs/compliance/cis/models.py` as the reference. Fields typically include `Provider`, `Description`, `AccountId`, `Region`, `AssessmentDate`, `Requirements_Id`, `Requirements_Description`, one `Requirements_Attributes_*` field per attribute key, plus the finding fields `Status`, `StatusExtended`, `ResourceId`, `ResourceName`, `CheckId`, `Muted`, `Framework`, `Name`.
#### Step 2 Implement the transformer
### Step 2 Implement the Transformer Class
In `my_framework_aws.py`, subclass `ComplianceOutput` from `prowler.lib.outputs.compliance.compliance_output` and implement `transform(findings, compliance, compliance_name)`. Iterate over `findings`, match each finding to the requirements it satisfies through `finding.compliance.get(compliance_name, [])`, and append one row per attribute to `self._data`.
#### Step 3 Add the summary-table dispatcher
### Step 3 Add the Summary-Table Dispatcher
In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_metadata, compliance_framework, output_filename, output_directory, compliance_overview)` following the pattern in `prowler/lib/outputs/compliance/cis/cis.py`.
#### Step 4 Register the framework in the dispatchers
### Step 4 Register the Framework in the Dispatchers
- Add the dispatcher call in `prowler/lib/outputs/compliance/compliance.py`, inside `display_compliance_table`, with a branch such as `elif "my_framework" in compliance_framework:`.
- Register the CSV model and transformer in `prowler/lib/outputs/compliance/compliance_output.py` so the CSV file is emitted during the scan.
@@ -470,94 +280,49 @@ In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_me
For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no custom formatter is needed. The generic formatter in `prowler/lib/outputs/compliance/generic/` handles them automatically, provided the JSON validates against the generic attribute schema.
</Note>
### Legacy-to-universal adapter
At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py:819`), which: (a) flattens the first element of `Attributes` into a flat `attributes` dict, (b) wraps `Checks` as `{provider_lower: [...]}`, (c) infers `attributes_metadata` from the matched Pydantic class via `_infer_attribute_metadata()`. The rest of Prowler (CSV/OCSF/PDF output, CLI table) then treats both formats identically.
Loader-error behaviour differs between the two entry points:
- `load_compliance_framework()` (legacy) is **fail-fast**: it calls `sys.exit(1)` on any `ValidationError` (`compliance_models.py:464`).
- `load_compliance_framework_universal()` is more lenient — it logs the error and returns `None`, so `get_bulk_compliance_frameworks_universal()` simply skips the broken file and keeps loading the rest.
## Version handling
## Version Handling
Prowler matches frameworks by concatenating `Framework` and `Version`. A missing or empty `Version` collapses several frameworks to the same key and breaks CLI filtering with `--compliance`.
- Always set `Version` (or `version` for universal frameworks) to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`, `2022/2554`).
- Always set `Version` to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`).
- When the source catalog has no version, use the first year of adoption or the release date.
- For **legacy** files, make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version.
- Make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version.
## Validating Your Framework
## Validating the Framework Locally
Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists.
Follow the steps below before opening a pull request.
### 1. Schema validation
For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora.json` that key is `dora`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`.
```python
from prowler.lib.check.compliance_models import (
load_compliance_framework_universal,
get_bulk_compliance_frameworks_universal,
)
fw = load_compliance_framework_universal("prowler/compliance/<your_framework>.json")
assert fw is not None, "load returned None — check the logs for the validation error"
print(fw.framework, len(fw.requirements), fw.get_providers())
bulk = get_bulk_compliance_frameworks_universal("aws")
assert "<your_framework_filename_without_json>" in bulk
```
### 2. Check existence cross-check
There is **no automatic check-existence validation** at load time. Cross-check that every check name in your framework maps to a real check directory:
```python
import os
real = set()
for svc in os.listdir("prowler/providers/aws/services"):
svc_path = f"prowler/providers/aws/services/{svc}"
if not os.path.isdir(svc_path):
continue
for entry in os.listdir(svc_path):
if os.path.isfile(f"{svc_path}/{entry}/{entry}.metadata.json"):
real.add(entry)
referenced = {c for r in fw.requirements for c in r.checks.get("aws", [])}
missing = referenced - real
assert not missing, f"checks referenced in framework but not found in repo: {sorted(missing)}"
```
### 3. CLI smoke test
### 1. Run the Compliance Model Validator
```bash
uv run python prowler-cli.py <provider> --list-compliance
```
The framework must appear in the output. A validation error indicates a schema mismatch.
The framework must appear in the output. A validation error indicates a schema mismatch between the JSON file and the attribute model.
### 2. Run a Scan Filtered by the Framework
```bash
uv run python prowler-cli.py <provider> \
--compliance <framework_key> \
--compliance <framework>_<version>_<provider> \
--log-level ERROR
```
Verify that:
- Prowler produces a CSV file under `output/compliance/` with the expected name.
- The CLI summary table lists every section / pillar of the framework.
- The CLI summary table lists every section in the framework.
- Findings roll up under the expected requirements.
### 4. Inspect the CSV output
### 3. Inspect the CSV Output
Open the generated CSV and confirm:
- All columns defined in `models.py` (legacy) or in `attributes_metadata` (universal) appear.
- Every requirement has at least one row per scanned resource (when there are findings).
- Attribute values such as `Requirements_Attributes_Section` reflect the JSON content.
- All columns defined in `models.py` appear.
- Every requirement has at least one row per scanned resource.
- Values such as `Requirements_Attributes_Section` reflect the JSON content.
### 5. Verify the framework in Prowler App
### 4. Verify the Framework in Prowler App
Launch Prowler App locally (`docker compose up` from the repository root) and run a scan with the new compliance framework. Confirm the compliance page renders the requirements, sections, and status widgets correctly.
@@ -566,7 +331,7 @@ Launch Prowler App locally (`docker compose up` from the repository root) and ru
Compliance contributions require two layers of tests.
- **Schema tests** exercise the Pydantic models. Extend `tests/lib/check/universal_compliance_models_test.py` with a case that loads the new JSON file and asserts the attribute type matches the expected model.
- **Output tests** (legacy frameworks only) exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance/<framework>/` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows.
- **Output tests** exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance/<framework>/` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows.
Run the suite with:
@@ -577,20 +342,7 @@ uv run pytest -n auto tests/lib/check/universal_compliance_models_test.py \
For guidance on writing Prowler SDK tests, refer to [Unit Testing](/developer-guide/unit-testing).
## Running and listing your framework
Once the file is in place, the CLI auto-discovers it:
```sh
prowler <provider> --list-compliance # framework appears in the list
prowler <provider> --compliance <framework_key> --list-checks
prowler <provider> --compliance <framework_key> # full scan + compliance report
prowler <provider> --compliance <framework_key> --list-compliance-requirements <framework_key>
```
For end-user-facing tutorials (recommended for high-profile frameworks), add a dedicated page under `docs/user-guide/compliance/tutorials/` and register it in the `"Compliance"` group of `docs/docs.json`. See `docs/user-guide/compliance/tutorials/threatscore.mdx` as a reference.
## Submitting the pull request
## Submitting the Pull Request
Before opening the pull request:
@@ -600,31 +352,28 @@ Before opening the pull request:
uv run pytest -n auto
```
2. Add a changelog entry under the `### 🚀 Added` section of `prowler/CHANGELOG.md`, describing the new framework and the providers it covers.
3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, e.g. `feat(compliance): add My Framework 1.0 for AWS`.
3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, for example `feat(compliance): add My Framework 1.0 for AWS`.
4. Request review from the compliance codeowners listed in `.github/CODEOWNERS`.
## Troubleshooting
The following issues are the most common when contributing a compliance framework.
- **`ValidationError: field required` during scan (legacy).** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`.
- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values (legacy).** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Keep the generic model in the last Union position and ensure every required field is present in the JSON.
- **`attributes_metadata validation failed` (universal).** The root validator in `compliance_models.py:669` rejected the file. The error message lists each offending requirement; common causes are unknown attribute keys (typo or missing entry in `attributes_metadata`), enum violations, or missing required keys.
- **`--compliance` filter does not find the framework.** For legacy: the filename does not match `<framework>_<version>_<provider>.json`, the version is empty, or the file lives outside `prowler/compliance/<provider>/`. For universal: the file is not at the top level of `prowler/compliance/` or it loaded as `None` (check logs for the validation error).
- **CLI summary table is empty but the CSV is populated (legacy).** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key.
- **CSV file is missing after the scan (legacy).** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`.
- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep <check_name>` to confirm, or run the check-existence cross-check from "Validating Your Framework".
- **`ValidationError: field required` during scan.** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`.
- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values.** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Move the generic model to the last Union position and ensure every required field is present in the JSON.
- **`--compliance` filter does not find the framework.** The filename does not match the expected pattern `<framework>_<version>_<provider>.json`, the version is empty, or the file lives outside `prowler/compliance/<provider>/`.
- **CLI summary table is empty but the CSV is populated.** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key.
- **CSV file is missing after the scan.** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`.
- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep <check_name>` to confirm.
## Reference examples
## Reference Examples
Use the following files as templates when modeling a new contribution.
- `prowler/compliance/dora.json` — universal schema, single-provider populated (AWS), ready to extend with more providers.
- `prowler/compliance/csa_ccm_4.0.json` — universal schema, multi-provider populated (AWS, Azure, GCP, AlibabaCloud, OracleCloud).
- `prowler/compliance/aws/cis_2.0_aws.json` — legacy CIS attribute shape.
- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` — legacy generic attribute shape.
- `prowler/compliance/aws/ccc_aws.json` — legacy CCC attribute shape.
- `prowler/compliance/azure/ens_rd2022_azure.json` — legacy ENS attribute shape.
- `prowler/lib/check/compliance_models.py` — canonical Pydantic schemas for both formats.
- `prowler/lib/outputs/compliance/cis/` — reference implementation of a multi-provider legacy output formatter.
- `prowler/lib/outputs/compliance/generic/` — reference implementation of a legacy generic output formatter.
- `prowler/compliance/aws/cis_2.0_aws.json` CIS attribute shape.
- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` Generic attribute shape.
- `prowler/compliance/aws/ccc_aws.json` CCC attribute shape.
- `prowler/compliance/azure/ens_rd2022_azure.json` ENS attribute shape.
- `prowler/lib/check/compliance_models.py` Canonical Pydantic schemas.
- `prowler/lib/outputs/compliance/cis/` Reference implementation of a multi-provider output formatter.
- `prowler/lib/outputs/compliance/generic/` Reference implementation of a generic output formatter.
@@ -35,28 +35,14 @@ The bundled checks require the following read-only scopes:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
- `okta.authenticators.read`
- `okta.networkZones.read`
- `okta.apiTokens.read`
- `okta.roles.read`
- `okta.groups.read`
- `okta.logStreams.read`
- `okta.idps.read`
Additional scopes will be needed as more services and checks are added. These are the current ones needed:
| Scope | Used by |
|---|---|
| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies |
| `okta.policies.read` | Sign-on, password, and authentication policies |
| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) |
| `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications |
| `okta.authenticators.read` | Okta authenticator configuration, including Okta Verify and Smart Card IdP |
| `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation |
| `okta.apiTokens.read` | API token metadata and token network conditions |
| `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) |
| `okta.groups.read` | Group memberships of API token owners, used to resolve admin roles inherited via group assignment (e.g. Super Admin granted through the default admin group) |
| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) |
| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) |
### Required Admin Role
@@ -82,9 +68,7 @@ Okta filters the first-party apps (`saasure`, `okta_enduser`) out of `/api/v1/ap
A fifth check — `application_admin_console_session_idle_timeout_15min` (STIG V-273187) — also requires Super Administrator: it calls `GET /api/v1/first-party-app-settings/admin-console`, which returns `403 E0000006` for every role below Super Administrator.
`user_inactivity_automation_35d_enabled` (STIG V-273188) reads `USER_LIFECYCLE` policies (`list_policies(type='USER_LIFECYCLE')`) using the `okta.policies.read` scope. The Read-Only Administrator role is enough to list them; no Super Administrator requirement.
When the service app runs with Read-Only Administrator, the checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running.
When the service app runs with Read-Only Administrator, the five checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running.
<Note>
Read-Only Administrator stays the recommended default for the least-privilege framing that aligns with DISA STIG. Assign Super Administrator on a separate run when full coverage of the first-party app checks is needed.
@@ -138,7 +122,7 @@ Okta displays the private key **only once**. If you close the modal without copy
### 5. Grant the required OAuth scopes
On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`.
On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, and `okta.apps.read`.
![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png)
@@ -174,8 +158,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# or
export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
uv run python prowler-cli.py okta
```
@@ -216,7 +200,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, or `okta.apps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
### Application-service checks return MANUAL on first-party apps
@@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid
- An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names.
- A **Super Administrator** account on that organization for the one-time service-app setup.
- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, and `okta.apps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
- Python 3.10+ and Prowler 5.27.0 or later installed locally.
<CardGroup cols={2}>
@@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid
export OKTA_ORG_DOMAIN="acme.okta.com"
export OKTA_CLIENT_ID="0oa1234567890abcdef"
export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
```
The private key file may contain either a PEM-encoded RSA key or a JWK JSON document.
@@ -143,16 +143,10 @@ prowler okta --config-file /path/to/config.yaml
Prowler for Okta includes security checks across the following services:
| Service | Description |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
| **Authenticator** | Password Policy controls plus Okta Verify FIPS and Smart Card IdP authenticator status |
| **Network** | Network Zone blocklists for anonymized proxy sources |
| **API Token** | API token owner-role validation and Network Zone restrictions |
| **User** | User lifecycle automations (inactivity-based deprovisioning) |
| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM |
| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility |
| Service | Description |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
## Troubleshooting
@@ -164,29 +158,22 @@ This is stricter than simply finding the same timeout value somewhere else in th
### Default Scopes
Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Authenticator, Network, API Token, User, System Log, and Identity Provider services:
Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On and Application services:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
- `okta.authenticators.read`
- `okta.networkZones.read`
- `okta.apiTokens.read`
- `okta.roles.read`
- `okta.groups.read`
- `okta.logStreams.read`
- `okta.idps.read`
The service app must have these scopes granted in the **Okta API Scopes** tab. `okta.groups.read` is required so the API token Super Admin check can resolve admin roles inherited via group membership; without it the check falls back to direct-only role assignments and emits a best-effort caveat. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
The service app must have these scopes granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
When additional checks are enabled — or when running against a service app that exposes a different scope set — override the default with `OKTA_SCOPES` (comma-separated string for the env var) or `--okta-scopes` (space-separated list for the CLI):
```bash
# Environment variable — comma-separated
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.users.read"
# CLI flag — space-separated
prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.authenticators.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read
prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.users.read
```
For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/).
@@ -47,11 +47,7 @@ Follow these steps to remove a user of your account:
1. Navigate to **Users** from the side menu.
2. Click the delete button of your current user.
> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
+16 -3
View File
@@ -12,12 +12,25 @@ reason = """
CVE-2025-45768 is disputed by the pyjwt maintainers. The advisory describes
weak encryption, but the underlying issue is that callers may pick a short
HMAC secret key-length enforcement is the application's responsibility, not
a defect in the library. We are on pyjwt 2.13.0 (which now also emits an
InsecureKeyLengthWarning for short HMAC secrets) and enforce key strength in
our own auth code, so this advisory does not apply.
a defect in the library. We are on pyjwt 2.12.1 (latest at pin time) and
enforce key strength in our own auth code, so this advisory does not apply.
Re-evaluate when a non-disputed advisory or upstream fix lands.
"""
[[IgnoredVulns]]
id = "GHSA-897w-fcg9-f6xj"
ignoreUntil = 2026-09-01T00:00:00Z
reason = """
Temporary suppression for api/uv.lock only. The SDK (root pyproject.toml) is
already bumped to dulwich==1.2.6, which fixes this advisory (patched in 1.2.5).
api/uv.lock resolves dulwich transitively through `prowler @ git+...@master`,
which still pins dulwich==0.23.0 at the locked commit, so api cannot upgrade
until the SDK fix lands on master and api/uv.lock is regenerated against the
new commit. The advisory is also Windows-only (arbitrary file write via
NTFS-hostile tree entries); the API runs in Linux containers. Remove this entry
once api/uv.lock is refreshed and no longer resolves dulwich 0.23.0.
"""
[[IgnoredVulns]]
id = "PYSEC-2026-89"
ignoreUntil = 2026-08-20T00:00:00Z
-50
View File
@@ -2,56 +2,6 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.30.0] (Prowler UNRELEASED)
### 🚀 Added
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465)
- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463)
- Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464)
- 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)
- `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516)
- `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215)
### 🐞 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)
- `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511)
- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510)
- GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516)
---
## [5.29.3] (Prowler v5.29.3)
### 🐞 Fixed
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488)
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
### 🔐 Security
- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
---
## [5.29.1] (Prowler v5.29.1)
### 🐞 Fixed
- OCSF output writer now re-raises I/O errors (e.g. `ENOSPC`) instead of logging them per finding and leaving a truncated file [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
---
## [5.29.0] (Prowler v5.29.0)
### 🚀 Added
+77 -57
View File
@@ -10,6 +10,7 @@ from colorama import Fore, Style
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,
@@ -84,6 +85,11 @@ from prowler.lib.outputs.compliance.compliance import (
display_compliance_table,
process_universal_compliance_frameworks,
)
from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA
from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA
from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA
from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS
from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS
from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS
@@ -204,10 +210,9 @@ 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 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):
# 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"]:
args.output_formats.extend(get_available_compliance_frameworks(provider))
# Set Logger configuration
@@ -245,7 +250,7 @@ def prowler():
universal_frameworks = {}
# Skip compliance frameworks for external-tool providers
if not Provider.is_tool_wrapper_provider(provider):
if provider not in EXTERNAL_TOOL_PROVIDERS:
bulk_compliance_frameworks = Compliance.get_bulk(provider)
# Complete checks metadata with the compliance framework specification
bulk_checks_metadata = update_checks_metadata_with_compliance(
@@ -313,7 +318,7 @@ def prowler():
sys.exit()
# Skip service and check loading for external-tool providers
if not Provider.is_tool_wrapper_provider(provider):
if provider not in EXTERNAL_TOOL_PROVIDERS:
# Import custom checks from folder
if checks_folder:
custom_checks = parse_checks_from_folder(global_provider, checks_folder)
@@ -436,20 +441,6 @@ def prowler():
output_options = ScalewayOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
else:
# Dynamic fallback: any external/custom provider
try:
output_options = global_provider.get_output_options(
args, bulk_checks_metadata
)
except NotImplementedError:
# No provider-specific OutputOptions: use the generic default so the
# run still produces output instead of aborting.
from prowler.providers.common.models import default_output_options
output_options = default_output_options(
global_provider, args, bulk_checks_metadata
)
# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
@@ -459,7 +450,7 @@ def prowler():
# Execute checks
findings = []
if Provider.is_tool_wrapper_provider(provider):
if provider in EXTERNAL_TOOL_PROVIDERS:
# For external-tool providers, run the scan directly
if provider == "llm":
@@ -469,19 +460,12 @@ def prowler():
findings = global_provider.run_scan(streaming_callback=streaming_callback)
else:
if provider == "image":
try:
findings = global_provider.run()
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
else:
# IAC and external tool-wrapper providers registered via entry
# points. Unexpected failures propagate to the outer except
# Exception backstop further down in this file — keeping the
# branch free of an Image-specific catch that would otherwise
# mislead plug-in authors reading this code.
# Original behavior for IAC and Image
try:
findings = global_provider.run()
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
# Note: External tool providers don't support granular progress tracking since
# they run external tools as a black box and return all findings at once.
# Progress tracking would just be 0% → 100%.
@@ -822,6 +806,18 @@ def prowler():
)
generated_outputs["compliance"].append(c5)
c5.batch_write_data_to_file()
elif compliance_name == "csa_ccm_4.0_aws":
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
csa_ccm_4_0_aws = AWSCSA(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(csa_ccm_4_0_aws)
csa_ccm_4_0_aws.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
@@ -925,6 +921,18 @@ def prowler():
)
generated_outputs["compliance"].append(c5_azure)
c5_azure.batch_write_data_to_file()
elif compliance_name == "csa_ccm_4.0_azure":
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
csa_ccm_4_0_azure = AzureCSA(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(csa_ccm_4_0_azure)
csa_ccm_4_0_azure.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
@@ -1028,6 +1036,18 @@ def prowler():
)
generated_outputs["compliance"].append(c5_gcp)
c5_gcp.batch_write_data_to_file()
elif compliance_name == "csa_ccm_4.0_gcp":
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
csa_ccm_4_0_gcp = GCPCSA(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(csa_ccm_4_0_gcp)
csa_ccm_4_0_gcp.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
@@ -1262,6 +1282,18 @@ def prowler():
)
generated_outputs["compliance"].append(cis)
cis.batch_write_data_to_file()
elif compliance_name == "csa_ccm_4.0_oraclecloud":
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
csa_ccm_4_0_oraclecloud = OracleCloudCSA(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(csa_ccm_4_0_oraclecloud)
csa_ccm_4_0_oraclecloud.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
@@ -1290,6 +1322,18 @@ def prowler():
)
generated_outputs["compliance"].append(cis)
cis.batch_write_data_to_file()
elif compliance_name == "csa_ccm_4.0_alibabacloud":
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
csa_ccm_4_0_alibabacloud = AlibabaCloudCSA(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(csa_ccm_4_0_alibabacloud)
csa_ccm_4_0_alibabacloud.batch_write_data_to_file()
elif compliance_name == "prowler_threatscore_alibabacloud":
filename = (
f"{output_options.output_directory}/compliance/"
@@ -1314,30 +1358,6 @@ def prowler():
)
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":
File diff suppressed because it is too large Load Diff
@@ -1863,9 +1863,7 @@
"Id": "ELB.4",
"Name": "Application load balancers should be configured to drop HTTP headers",
"Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.",
"Checks": [
"elbv2_alb_drop_invalid_header_fields_enabled"
],
"Checks": [],
"Attributes": [
{
"ItemId": "ELB.4",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-597
View File
@@ -1,597 +0,0 @@
{
"framework": "DORA",
"name": "Digital Operational Resilience Act (Regulation (EU) 2022/2554)",
"version": "2022/2554",
"description": "The Digital Operational Resilience Act (DORA) is a European Union regulation (Regulation (EU) 2022/2554) that sets a uniform framework for the digital operational resilience of the EU financial sector. Mandatory since 17 January 2025, it applies to financial entities (banks, insurers, investment firms, payment institutions, etc.) and to ICT third-party service providers. DORA is structured around five pillars: ICT risk management, ICT-related incident reporting, digital operational resilience testing, ICT third-party risk management, and information sharing. This Prowler mapping covers the technical controls auditable from cloud configuration; the organisational, contractual and supervisory obligations defined in DORA must be addressed outside of Prowler.",
"icon": "dora",
"attributes_metadata": [
{
"key": "Pillar",
"label": "Pillar",
"type": "str",
"required": true,
"enum": [
"ICT Risk Management",
"ICT-Related Incident Reporting",
"Digital Operational Resilience Testing",
"ICT Third-Party Risk Management",
"Information Sharing"
],
"output_formats": {
"csv": true,
"ocsf": true
}
},
{
"key": "Article",
"label": "Article",
"type": "str",
"required": true,
"output_formats": {
"csv": true,
"ocsf": true
}
},
{
"key": "ArticleTitle",
"label": "Article Title",
"type": "str",
"required": true,
"output_formats": {
"csv": true,
"ocsf": true
}
}
],
"outputs": {
"table_config": {
"group_by": "Pillar"
},
"pdf_config": {
"language": "en",
"primary_color": "#003399",
"secondary_color": "#0055A5",
"bg_color": "#F0F4FA",
"group_by_field": "Pillar",
"sections": [
"ICT Risk Management",
"ICT-Related Incident Reporting",
"Digital Operational Resilience Testing",
"ICT Third-Party Risk Management",
"Information Sharing"
],
"section_short_names": {
"ICT Risk Management": "ICT Risk Mgmt",
"ICT-Related Incident Reporting": "Incident Reporting",
"Digital Operational Resilience Testing": "Resilience Testing",
"ICT Third-Party Risk Management": "Third-Party Risk",
"Information Sharing": "Info Sharing"
},
"charts": [
{
"id": "pillar_compliance",
"type": "horizontal_bar",
"group_by": "Pillar",
"title": "Compliance Score by DORA Pillar",
"y_label": "Pillar",
"x_label": "Compliance %",
"value_source": "compliance_percent",
"color_mode": "by_value"
}
],
"filter": {
"only_failed": true,
"include_manual": false
}
}
},
"requirements": [
{
"id": "DORA-Art5",
"name": "Governance and organisation",
"description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. Senior management is accountable for ICT risk and shall enforce strong identity, authentication and least-privilege policies for privileged identities, including the root account.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 5",
"ArticleTitle": "Governance and organisation"
},
"checks": {
"aws": [
"iam_avoid_root_usage",
"iam_no_root_access_key",
"iam_root_mfa_enabled",
"iam_root_hardware_mfa_enabled",
"iam_root_credentials_management_enabled",
"iam_password_policy_minimum_length_14",
"iam_password_policy_lowercase",
"iam_password_policy_uppercase",
"iam_password_policy_number",
"iam_password_policy_symbol",
"iam_password_policy_reuse_24",
"iam_password_policy_expires_passwords_within_90_days_or_less",
"iam_securityaudit_role_created",
"iam_support_role_created",
"organizations_account_part_of_organizations",
"iam_user_mfa_enabled_console_access",
"iam_user_hardware_mfa_enabled"
]
}
},
{
"id": "DORA-Art6",
"name": "ICT risk management framework",
"description": "Financial entities shall have an ICT risk management framework that is sound, comprehensive and well-documented, enabling them to address ICT risk quickly, efficiently and comprehensively and to ensure a high level of digital operational resilience. This includes continuous configuration recording, security findings aggregation and an enterprise-wide visibility plane.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 6",
"ArticleTitle": "ICT risk management framework"
},
"checks": {
"aws": [
"config_recorder_all_regions_enabled",
"config_recorder_using_aws_service_role",
"securityhub_enabled",
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings",
"organizations_delegated_administrators",
"guardduty_centrally_managed",
"guardduty_delegated_admin_enabled_all_regions"
]
}
},
{
"id": "DORA-Art7",
"name": "ICT systems, protocols and tools",
"description": "Financial entities shall use and maintain updated ICT systems, protocols and tools that are appropriate to the magnitude of operations supporting ICT functions, technologically resilient, and adequately equipped to securely process data. Cryptographic primitives, certificate hygiene and network segmentation are core to this requirement.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 7",
"ArticleTitle": "ICT systems, protocols and tools"
},
"checks": {
"aws": [
"acm_certificates_with_secure_key_algorithms",
"acm_certificates_transparency_logs_enabled",
"acm_certificates_expiration_check",
"ec2_ebs_default_encryption",
"kms_cmk_rotation_enabled",
"s3_bucket_secure_transport_policy",
"s3_bucket_default_encryption",
"s3_bucket_kms_encryption",
"vpc_subnet_separate_private_public",
"vpc_subnet_no_public_ip_by_default",
"elb_insecure_ssl_ciphers",
"elbv2_insecure_ssl_ciphers",
"elb_ssl_listeners",
"elbv2_ssl_listeners",
"cloudfront_distributions_using_deprecated_ssl_protocols",
"cloudfront_distributions_https_enabled",
"rds_instance_transport_encrypted"
]
}
},
{
"id": "DORA-Art8",
"name": "Identification",
"description": "Financial entities shall identify, classify and adequately document all ICT supported business functions, roles and responsibilities, the information assets and ICT assets supporting them, and their interdependencies. They shall on a continuous basis identify all sources of ICT risk, in particular the risk exposure to and from other financial entities.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 8",
"ArticleTitle": "Identification"
},
"checks": {
"aws": [
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings",
"macie_is_enabled",
"macie_automated_sensitive_data_discovery_enabled",
"ec2_securitygroup_not_used",
"ec2_elastic_ip_unassigned",
"ec2_networkacl_unused",
"secretsmanager_secret_unused"
]
}
},
{
"id": "DORA-Art9",
"name": "Protection and prevention",
"description": "Financial entities shall continuously monitor and control the security and functioning of ICT systems and tools and minimise the impact of ICT risk by deploying appropriate ICT security tools, policies and procedures. Encryption at rest and in transit, blocking of public exposure, network access controls, secret management and instance hardening are central to this article.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 9",
"ArticleTitle": "Protection and prevention"
},
"checks": {
"aws": [
"kms_key_not_publicly_accessible",
"ec2_ebs_volume_encryption",
"ec2_ebs_snapshots_encrypted",
"ec2_ebs_public_snapshot",
"ec2_ebs_snapshot_account_block_public_access",
"s3_account_level_public_access_blocks",
"s3_bucket_level_public_access_block",
"s3_bucket_public_access",
"s3_bucket_policy_public_write_access",
"s3_bucket_public_write_acl",
"s3_bucket_public_list_acl",
"s3_bucket_acl_prohibited",
"s3_access_point_public_access_block",
"ec2_securitygroup_default_restrict_traffic",
"ec2_securitygroup_allow_ingress_from_internet_to_all_ports",
"ec2_securitygroup_allow_ingress_from_internet_to_any_port",
"ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports",
"ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22",
"ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389",
"rds_instance_storage_encrypted",
"rds_cluster_storage_encrypted",
"rds_instance_no_public_access",
"rds_snapshots_public_access",
"secretsmanager_not_publicly_accessible",
"secretsmanager_has_restrictive_resource_policy",
"secretsmanager_automatic_rotation_enabled",
"dynamodb_tables_kms_cmk_encryption_enabled",
"sns_topics_kms_encryption_at_rest_enabled",
"sns_topics_not_publicly_accessible",
"ec2_instance_imdsv2_enabled",
"ec2_instance_account_imdsv2_enabled",
"efs_encryption_at_rest_enabled",
"awslambda_function_not_publicly_accessible"
]
}
},
{
"id": "DORA-Art10",
"name": "Detection",
"description": "Financial entities shall have in place mechanisms to promptly detect anomalous activities, including ICT network performance issues and ICT-related incidents, and to identify potential single points of failure. Threat detection across compute, identity, storage and the API control plane is required for timely detection.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 10",
"ArticleTitle": "Detection"
},
"checks": {
"aws": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings",
"guardduty_ec2_malware_protection_enabled",
"guardduty_lambda_protection_enabled",
"guardduty_rds_protection_enabled",
"guardduty_s3_protection_enabled",
"guardduty_eks_audit_log_enabled",
"guardduty_eks_runtime_monitoring_enabled",
"securityhub_enabled",
"cloudtrail_threat_detection_enumeration",
"cloudtrail_threat_detection_llm_jacking",
"cloudtrail_threat_detection_privilege_escalation",
"cloudtrail_insights_exist",
"inspector2_is_enabled",
"inspector2_active_findings_exist",
"ec2_elastic_ip_shodan"
]
}
},
{
"id": "DORA-Art11",
"name": "Response and recovery",
"description": "Financial entities shall put in place a comprehensive ICT business continuity policy, including ICT response and recovery plans, that ensures the continuity of ICT-supported critical or important functions. Operational alarming, automated event routing and tested recovery actions are essential.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 11",
"ArticleTitle": "Response and recovery"
},
"checks": {
"aws": [
"cloudwatch_alarm_actions_enabled",
"cloudwatch_alarm_actions_alarm_state_configured",
"eventbridge_global_endpoint_event_replication_enabled",
"sns_subscription_not_using_http_endpoints",
"backup_plans_exist",
"backup_vaults_exist",
"rds_instance_critical_event_subscription",
"rds_cluster_critical_event_subscription"
]
}
},
{
"id": "DORA-Art12",
"name": "Backup policies and procedures, restoration and recovery procedures and methods",
"description": "Financial entities shall develop and document backup policies and procedures specifying the scope of data subject to backup and the minimum frequency of the backup, as well as restoration and recovery procedures and methods. Backups must be encrypted, retained, and resources must be designed for recoverability across availability zones and regions.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 12",
"ArticleTitle": "Backup policies and procedures, restoration and recovery procedures and methods"
},
"checks": {
"aws": [
"backup_plans_exist",
"backup_vaults_exist",
"backup_vaults_encrypted",
"backup_recovery_point_encrypted",
"backup_reportplans_exist",
"rds_instance_backup_enabled",
"rds_cluster_protected_by_backup_plan",
"rds_instance_protected_by_backup_plan",
"rds_instance_multi_az",
"rds_cluster_multi_az",
"rds_cluster_backtrack_enabled",
"rds_instance_deletion_protection",
"rds_cluster_deletion_protection",
"rds_snapshots_encrypted",
"s3_bucket_object_versioning",
"s3_bucket_object_lock",
"s3_bucket_cross_region_replication",
"s3_bucket_no_mfa_delete",
"dynamodb_tables_pitr_enabled",
"dynamodb_table_deletion_protection_enabled",
"ec2_ebs_volume_protected_by_backup_plan",
"ec2_ebs_volume_snapshots_exists",
"autoscaling_group_multiple_az",
"elb_is_in_multiple_az",
"elbv2_is_in_multiple_az",
"cloudfront_distributions_multiple_origin_failover_configured",
"dynamodb_table_protected_by_backup_plan"
]
}
},
{
"id": "DORA-Art13",
"name": "Learning and evolving",
"description": "Financial entities shall have in place capabilities and staff to gather information on vulnerabilities and cyber threats, perform post ICT-related incident reviews, and continuously feed lessons learnt back into the ICT risk assessment process. Findings aggregation and continuous insights drive this cycle.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 13",
"ArticleTitle": "Learning and evolving"
},
"checks": {
"aws": [
"securityhub_enabled",
"guardduty_no_high_severity_findings",
"inspector2_active_findings_exist",
"accessanalyzer_enabled_without_findings",
"cloudtrail_insights_exist"
]
}
},
{
"id": "DORA-Art14",
"name": "Communication",
"description": "As part of the ICT risk management framework, financial entities shall have in place crisis communication plans enabling a responsible disclosure of ICT-related incidents or major vulnerabilities to clients, counterparts and the public. Reliable, encrypted and access-controlled notification channels are required.",
"attributes": {
"Pillar": "ICT Risk Management",
"Article": "Article 14",
"ArticleTitle": "Communication"
},
"checks": {
"aws": [
"sns_topics_kms_encryption_at_rest_enabled",
"sns_topics_not_publicly_accessible",
"sns_subscription_not_using_http_endpoints",
"eventbridge_bus_exposed",
"eventbridge_bus_cross_account_access",
"eventbridge_schema_registry_cross_account_access",
"cloudwatch_alarm_actions_enabled",
"cloudwatch_alarm_actions_alarm_state_configured"
]
}
},
{
"id": "DORA-Art17",
"name": "ICT-related incident management process",
"description": "Financial entities shall define, establish and implement an ICT-related incident management process to detect, manage and notify ICT-related incidents. Comprehensive trail logging, log integrity protection, retention and centralisation of ICT events are foundational requirements.",
"attributes": {
"Pillar": "ICT-Related Incident Reporting",
"Article": "Article 17",
"ArticleTitle": "ICT-related incident management process"
},
"checks": {
"aws": [
"cloudtrail_multi_region_enabled",
"cloudtrail_multi_region_enabled_logging_management_events",
"cloudtrail_kms_encryption_enabled",
"cloudtrail_log_file_validation_enabled",
"cloudtrail_cloudwatch_logging_enabled",
"cloudtrail_logs_s3_bucket_access_logging_enabled",
"cloudtrail_logs_s3_bucket_is_not_publicly_accessible",
"cloudtrail_s3_dataevents_read_enabled",
"cloudtrail_s3_dataevents_write_enabled",
"cloudtrail_bucket_requires_mfa_delete",
"cloudtrail_bedrock_logging_enabled",
"cloudwatch_log_group_retention_policy_specific_days_enabled",
"cloudwatch_log_group_kms_encryption_enabled",
"cloudwatch_log_group_no_secrets_in_logs",
"cloudwatch_log_group_not_publicly_accessible",
"vpc_flow_logs_enabled",
"ec2_client_vpn_endpoint_connection_logging_enabled",
"route53_public_hosted_zones_cloudwatch_logging_enabled",
"elb_logging_enabled",
"elbv2_logging_enabled",
"cloudfront_distributions_logging_enabled",
"s3_bucket_server_access_logging_enabled"
]
}
},
{
"id": "DORA-Art18",
"name": "Classification of ICT-related incidents and cyber threats",
"description": "Financial entities shall classify ICT-related incidents and shall determine their impact based on criteria such as the number of clients affected, duration, geographical spread, data losses, and criticality of the services affected. Severity-aware threat detection across the estate underpins this classification.",
"attributes": {
"Pillar": "ICT-Related Incident Reporting",
"Article": "Article 18",
"ArticleTitle": "Classification of ICT-related incidents and cyber threats"
},
"checks": {
"aws": [
"guardduty_no_high_severity_findings",
"guardduty_centrally_managed",
"guardduty_delegated_admin_enabled_all_regions",
"securityhub_enabled",
"inspector2_active_findings_exist",
"accessanalyzer_enabled_without_findings",
"cloudtrail_threat_detection_enumeration",
"cloudtrail_threat_detection_llm_jacking",
"cloudtrail_threat_detection_privilege_escalation"
]
}
},
{
"id": "DORA-Art19",
"name": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats",
"description": "Financial entities shall report major ICT-related incidents to the relevant competent authority and may, on a voluntary basis, notify significant cyber threats. Detective metric filters, change-tracking alarms and reliable notification topics are needed to surface and route reportable events.",
"attributes": {
"Pillar": "ICT-Related Incident Reporting",
"Article": "Article 19",
"ArticleTitle": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats"
},
"checks": {
"aws": [
"cloudwatch_log_metric_filter_authentication_failures",
"cloudwatch_log_metric_filter_unauthorized_api_calls",
"cloudwatch_log_metric_filter_root_usage",
"cloudwatch_log_metric_filter_sign_in_without_mfa",
"cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk",
"cloudwatch_log_metric_filter_for_s3_bucket_policy_changes",
"cloudwatch_log_metric_filter_policy_changes",
"cloudwatch_log_metric_filter_security_group_changes",
"cloudwatch_log_metric_filter_aws_organizations_changes",
"cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled",
"cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled",
"cloudwatch_changes_to_network_acls_alarm_configured",
"cloudwatch_changes_to_network_gateways_alarm_configured",
"cloudwatch_changes_to_network_route_tables_alarm_configured",
"cloudwatch_changes_to_vpcs_alarm_configured",
"sns_subscription_not_using_http_endpoints"
]
}
},
{
"id": "DORA-Art24",
"name": "General requirements for the performance of digital operational resilience testing",
"description": "Financial entities shall establish, maintain and review a sound and comprehensive digital operational resilience testing programme, as an integral part of the ICT risk management framework. Continuous vulnerability discovery, configuration assessment and instance manageability are foundational.",
"attributes": {
"Pillar": "Digital Operational Resilience Testing",
"Article": "Article 24",
"ArticleTitle": "General requirements for the performance of digital operational resilience testing"
},
"checks": {
"aws": [
"inspector2_is_enabled",
"inspector2_active_findings_exist",
"securityhub_enabled",
"ec2_instance_managed_by_ssm",
"ec2_instance_with_outdated_ami",
"ssm_managed_compliant_patching"
]
}
},
{
"id": "DORA-Art25",
"name": "Testing of ICT tools and systems",
"description": "Financial entities shall ensure that tests are undertaken on ICT tools and systems, on critical ICT systems supporting all critical or important functions, at least yearly. Vulnerability assessments, deprecated component detection and certificate hygiene must be tracked.",
"attributes": {
"Pillar": "Digital Operational Resilience Testing",
"Article": "Article 25",
"ArticleTitle": "Testing of ICT tools and systems"
},
"checks": {
"aws": [
"inspector2_is_enabled",
"inspector2_active_findings_exist",
"guardduty_is_enabled",
"guardduty_no_high_severity_findings",
"config_recorder_all_regions_enabled",
"ec2_instance_with_outdated_ami",
"ec2_instance_managed_by_ssm",
"ec2_instance_paravirtual_type",
"rds_instance_deprecated_engine_version",
"acm_certificates_expiration_check",
"rds_instance_certificate_expiration",
"iam_no_expired_server_certificates_stored",
"ssm_managed_compliant_patching"
]
}
},
{
"id": "DORA-Art28",
"name": "General principles (ICT third-party risk)",
"description": "Financial entities shall manage ICT third-party risk as an integral component of ICT risk within their ICT risk management framework. Cross-account access, trust boundaries, organization-level controls and dependency visibility are critical to monitor third-party exposure on AWS.",
"attributes": {
"Pillar": "ICT Third-Party Risk Management",
"Article": "Article 28",
"ArticleTitle": "General principles (ICT third-party risk)"
},
"checks": {
"aws": [
"iam_role_cross_service_confused_deputy_prevention",
"iam_role_cross_account_readonlyaccess_policy",
"iam_no_custom_policy_permissive_role_assumption",
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings",
"s3_bucket_cross_account_access",
"dynamodb_table_cross_account_access",
"eventbridge_bus_cross_account_access",
"eventbridge_schema_registry_cross_account_access",
"cloudwatch_cross_account_sharing_disabled",
"organizations_delegated_administrators",
"organizations_account_part_of_organizations",
"organizations_scp_check_deny_regions",
"vpc_endpoint_connections_trust_boundaries",
"vpc_endpoint_services_allowed_principals_trust_boundaries",
"vpc_peering_routing_tables_with_least_privilege",
"awslambda_function_using_cross_account_layers"
]
}
},
{
"id": "DORA-Art30",
"name": "Key contractual provisions",
"description": "Contractual arrangements with ICT third-party service providers shall be set out in writing and include, at minimum, agreed service levels and clear allocation of rights and obligations. Privilege boundaries, least-privilege policies and absence of administrative wildcards are the technical guardrails that enforce these contractual constraints inside AWS.",
"attributes": {
"Pillar": "ICT Third-Party Risk Management",
"Article": "Article 30",
"ArticleTitle": "Key contractual provisions"
},
"checks": {
"aws": [
"iam_aws_attached_policy_no_administrative_privileges",
"iam_customer_attached_policy_no_administrative_privileges",
"iam_customer_unattached_policy_no_administrative_privileges",
"iam_inline_policy_no_administrative_privileges",
"iam_inline_policy_allows_privilege_escalation",
"iam_policy_allows_privilege_escalation",
"iam_inline_policy_no_full_access_to_cloudtrail",
"iam_inline_policy_no_full_access_to_kms",
"iam_policy_no_full_access_to_cloudtrail",
"iam_policy_no_full_access_to_kms",
"iam_role_administratoraccess_policy",
"iam_user_administrator_access_policy",
"iam_group_administrator_access_policy",
"iam_administrator_access_with_mfa",
"iam_policy_attached_only_to_group_or_roles",
"accessanalyzer_enabled"
]
}
},
{
"id": "DORA-Art45",
"name": "Information-sharing arrangements on cyber threat information and intelligence",
"description": "Financial entities may exchange amongst themselves cyber threat information and intelligence, including indicators of compromise, tactics, techniques and procedures, cyber security alerts and configuration tools. Centralised threat detection, sensitive data discovery and trail-based intelligence enable participation in such information-sharing arrangements.",
"attributes": {
"Pillar": "Information Sharing",
"Article": "Article 45",
"ArticleTitle": "Information-sharing arrangements on cyber threat information and intelligence"
},
"checks": {
"aws": [
"guardduty_is_enabled",
"guardduty_centrally_managed",
"securityhub_enabled",
"macie_is_enabled",
"macie_automated_sensitive_data_discovery_enabled",
"cloudtrail_threat_detection_enumeration",
"cloudtrail_threat_detection_llm_jacking",
"cloudtrail_threat_detection_privilege_escalation",
"accessanalyzer_enabled_without_findings"
]
}
}
]
}
+1 -1
View File
@@ -889,7 +889,7 @@
}
],
"Checks": [
"kms_key_rotation_max_90_days"
"kms_key_rotation_enabled"
]
},
{
+1 -1
View File
@@ -150,7 +150,7 @@
"Id": "1.10",
"Description": "Google Cloud Key Management Service stores cryptographic keys in a hierarchical structure designed for useful and elegant access control management. The format for the rotation schedule depends on the client library that is used. For the gcloud command-line tool, the next rotation time must be in `ISO` or `RFC3339` format, and the rotation period must be in the form `INTEGERUNIT`, where units can be one of seconds (s), minutes (m), hours (h) or days (d).",
"Checks": [
"kms_key_rotation_max_90_days"
"kms_key_rotation_enabled"
],
"Attributes": [
{
+1 -1
View File
@@ -201,7 +201,7 @@
"Id": "1.10",
"Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days",
"Checks": [
"kms_key_rotation_max_90_days"
"kms_key_rotation_enabled"
],
"Attributes": [
{
+1 -1
View File
@@ -201,7 +201,7 @@
"Id": "1.10",
"Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days",
"Checks": [
"kms_key_rotation_max_90_days"
"kms_key_rotation_enabled"
],
"Attributes": [
{
File diff suppressed because it is too large Load Diff
@@ -117,7 +117,7 @@
"Id": "1.2.4",
"Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days",
"Checks": [
"kms_key_rotation_max_90_days"
"kms_key_rotation_enabled"
],
"Attributes": [
{
File diff suppressed because it is too large Load Diff
+14 -86
View File
@@ -1,4 +1,3 @@
import importlib.metadata
import os
import pathlib
from datetime import datetime, timezone
@@ -86,38 +85,13 @@ 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 = []
# Built-in compliance
compliance_base = f"{actual_directory}/../compliance"
providers = [p.value for p in Provider]
if provider:
providers = [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}"
for current_provider in providers:
compliance_dir = f"{actual_directory}/../compliance/{current_provider}"
if not os.path.isdir(compliance_dir):
continue
with os.scandir(compliance_dir) as files:
@@ -126,8 +100,7 @@ def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks.append(
file.name.removesuffix(".json")
)
# Built-in multi-provider frameworks at top-level compliance/ directory.
# Placed before external entry points so built-ins win on name collisions.
# Also scan top-level compliance/ for multi-provider (universal) JSONs.
# 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"
@@ -144,43 +117,6 @@ def get_available_compliance_frameworks(provider=None):
continue
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
# 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:
continue
if os.path.isdir(path):
for file in os.scandir(path):
if file.is_file() and file.name.endswith(".json"):
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
@@ -292,26 +228,18 @@ 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)
# 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)
# 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"]
):
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
config = config_file.get(provider, {})
else:
config = {}
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 = {}
return config
+6 -54
View File
@@ -1,6 +1,4 @@
import importlib
import importlib.metadata
import importlib.util
import json
import os
import re
@@ -21,7 +19,6 @@ from prowler.lib.check.utils import recover_checks_from_provider
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
@@ -388,45 +385,6 @@ 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
@@ -567,10 +525,9 @@ def execute_checks(
service = check_name.split("_")[0]
try:
try:
# Import check module (built-in or entry point)
lib = _resolve_check_module(
global_provider.type, service, check_name
)
# Import check module
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -648,10 +605,9 @@ def execute_checks(
)
try:
try:
# Import check module (built-in or entry point)
lib = _resolve_check_module(
global_provider.type, service, check_name
)
# Import check module
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -797,10 +753,6 @@ def execute(
is_finding_muted_args["org_domain"] = (
global_provider.identity.org_domain
)
elif not is_builtin_provider(global_provider.type):
# 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
+3 -8
View File
@@ -2,10 +2,10 @@ import sys
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
@@ -26,13 +26,8 @@ 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 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):
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
return set()
# Local subsets
+15 -80
View File
@@ -1,4 +1,3 @@
import importlib.metadata
import json
import os
import sys
@@ -435,63 +434,26 @@ class Compliance(BaseModel):
"""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:
# Match the provider segment exactly, not as a substring, so
# e.g. `cloud` does not capture `cloudflare`.
if compliance_framework.name.split(".")[-1] == provider:
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
):
# 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}"
)
except Exception as e:
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
@@ -500,26 +462,18 @@ class Compliance(BaseModel):
# Testing Pending
def load_compliance_framework(
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.
"""
compliance_specification_file: str,
) -> Compliance:
"""load_compliance_framework loads and parse a Compliance Framework Specification"""
try:
return Compliance.parse_file(compliance_specification_file)
compliance_framework = Compliance.parse_file(compliance_specification_file)
except ValidationError as 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}"
logger.critical(
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
)
return None
sys.exit(1)
else:
return compliance_framework
# ─── Universal Compliance Schema Models (Phase 1-3) ─────────────────────────
@@ -996,25 +950,6 @@ 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
+12 -37
View File
@@ -11,10 +11,10 @@ from typing import Any, Dict, Optional, Set
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.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(
@@ -259,7 +259,7 @@ class CheckMetadata(BaseModel):
)
if (
value_lower not in VALID_CATEGORIES
and not ProviderABC.is_tool_wrapper_provider(values.get("Provider"))
and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS
):
raise ValueError(
f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}."
@@ -288,9 +288,7 @@ class CheckMetadata(BaseModel):
raise ValueError("ServiceName must be a non-empty string")
check_id = values.get("CheckID")
if check_id and not ProviderABC.is_tool_wrapper_provider(
values.get("Provider")
):
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
service_from_check_id = check_id.split("_")[0]
if service_name != service_from_check_id:
raise ValueError(
@@ -306,9 +304,7 @@ class CheckMetadata(BaseModel):
if not check_id:
raise ValueError("CheckID must be a non-empty string")
if check_id and not ProviderABC.is_tool_wrapper_provider(
values.get("Provider")
):
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if "-" in check_id:
raise ValueError(
f"CheckID {check_id} contains a hyphen, which is not allowed"
@@ -317,9 +313,8 @@ class CheckMetadata(BaseModel):
return check_id
@validator("CheckTitle", pre=True, always=True)
@classmethod
def validate_check_title(cls, check_title, values): # noqa: F841
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if len(check_title) > 150:
raise ValueError(
f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters"
@@ -331,18 +326,14 @@ class CheckMetadata(BaseModel):
return check_title
@validator("RelatedUrl", pre=True, always=True)
@classmethod
def validate_related_url(cls, related_url, values): # noqa: F841
if related_url and not ProviderABC.is_tool_wrapper_provider(
values.get("Provider")
):
if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
raise ValueError("RelatedUrl must be empty. This field is deprecated.")
return related_url
@validator("Remediation")
@classmethod
def validate_recommendation_url(cls, remediation, values): # noqa: F841
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
url = remediation.Recommendation.Url
if url and not url.startswith("https://hub.prowler.com/"):
raise ValueError(
@@ -355,7 +346,7 @@ class CheckMetadata(BaseModel):
provider = values.get("Provider", "").lower()
# Non-AWS providers must have an empty CheckType list
if provider != "aws" and not ProviderABC.is_tool_wrapper_provider(provider):
if provider != "aws" and provider not in EXTERNAL_TOOL_PROVIDERS:
if check_type:
raise ValueError(
f"CheckType must be empty for non-AWS providers. Got {check_type} for provider '{provider}'."
@@ -380,9 +371,8 @@ class CheckMetadata(BaseModel):
return check_type
@validator("Description", pre=True, always=True)
@classmethod
def validate_description(cls, description, values): # noqa: F841
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if len(description) > 400:
raise ValueError(
f"Description must not exceed 400 characters, got {len(description)} characters"
@@ -390,9 +380,8 @@ class CheckMetadata(BaseModel):
return description
@validator("Risk", pre=True, always=True)
@classmethod
def validate_risk(cls, risk, values): # noqa: F841
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if len(risk) > 400:
raise ValueError(
f"Risk must not exceed 400 characters, got {len(risk)} characters"
@@ -444,20 +433,6 @@ class CheckMetadata(BaseModel):
metadata_file = f"{check_path}/{check_name}.metadata.json"
# Load metadata
check_metadata = load_check_metadata(metadata_file)
# Built-in wins on CheckID collision. Plug-in entry points are
# appended after built-ins by `recover_checks_from_provider`, so
# a duplicate CheckID here means an entry-point check is trying
# to override a built-in. Ignore the override (the built-in
# metadata stays) and surface it via a warning — matching the
# precedence enforced by `_resolve_check_module`.
if check_metadata.CheckID in bulk_check_metadata:
logger.warning(
f"Plug-in check metadata '{check_metadata.CheckID}' "
f"(loaded from '{metadata_file}') is being IGNORED — "
f"a built-in with the same CheckID exists. To use your "
f"plug-in, register it under a different CheckID."
)
continue
bulk_check_metadata[check_metadata.CheckID] = check_metadata
return bulk_check_metadata
@@ -495,7 +470,7 @@ class CheckMetadata(BaseModel):
# If the bulk checks metadata is not provided, get it
if not bulk_checks_metadata:
bulk_checks_metadata = {}
available_providers = ProviderABC.get_available_providers()
available_providers = [p.value for p in Provider]
for provider_name in available_providers:
bulk_checks_metadata.update(CheckMetadata.get_bulk(provider_name))
if provider:
@@ -520,7 +495,7 @@ class CheckMetadata(BaseModel):
# Loaded here, as it is not always needed
if not bulk_compliance_frameworks:
bulk_compliance_frameworks = {}
available_providers = ProviderABC.get_available_providers()
available_providers = [p.value for p in Provider]
for provider in available_providers:
bulk_compliance_frameworks = Compliance.get_bulk(provider=provider)
checks_from_compliance_framework = (
-62
View File
@@ -1,62 +0,0 @@
"""Standalone helper for tool-wrapper provider detection.
A provider is a "tool wrapper" if it delegates scanning to an external tool
(Trivy, promptfoo, etc.) instead of running checks/services through the
standard Prowler engine. This module is the single source of truth for that
classification across the codebase.
Kept as a leaf module with no Prowler imports beyond the leaf
`external_tool_providers` so it can be referenced from `prowler.lib.check.*`
and `prowler.providers.common.provider` without forming an import cycle.
"""
import importlib.metadata
from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
from prowler.providers.common.builtin import is_builtin_provider
# Module-level cache for entry-point classes consulted by this helper.
# Independent of `Provider._ep_providers` to keep this module leaf — the cost
# of a duplicate cache entry is negligible (one class object per external
# provider, loaded lazily on first lookup).
_ep_class_cache: dict = {}
def _load_ep_class(provider: str):
"""Return the entry-point provider class for `provider`, or None.
Caches the result in `_ep_class_cache`. Errors during entry-point loading
are swallowed (returning None) so a broken plug-in never crashes the
is-tool-wrapper check; it just falls through to "not a tool wrapper".
"""
if provider in _ep_class_cache:
return _ep_class_cache[provider]
for ep in importlib.metadata.entry_points(group="prowler.providers"):
if ep.name == provider:
try:
cls = ep.load()
except Exception:
cls = None
_ep_class_cache[provider] = cls
return cls
_ep_class_cache[provider] = None
return None
def is_tool_wrapper_provider(provider: str) -> bool:
"""Return True if the provider delegates scanning to an external tool.
Combines the built-in `EXTERNAL_TOOL_PROVIDERS` frozenset (fast path for
iac/llm/image) with the `is_external_tool_provider` class attribute of
external plug-ins registered via entry points. This is the single source
of truth consulted by `__main__`, the `CheckMetadata` validators, the
check-loading utilities, and the checks loader.
"""
if provider in EXTERNAL_TOOL_PROVIDERS:
return True
# Built-in wins: short-circuit before ep.load() so a same-name plug-in
# cannot flip a built-in onto the tool-wrapper path or run its code.
if is_builtin_provider(provider):
return False
cls = _load_ep_class(provider)
return bool(cls and getattr(cls, "is_external_tool_provider", False))
+23 -84
View File
@@ -1,43 +1,9 @@
import importlib
import importlib.metadata
import importlib.util
import os
import sys
from pkgutil import walk_packages
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.logger import logger
from prowler.providers.common.builtin import is_builtin_provider
def _recover_ep_checks(provider: str, service: str = None) -> list[tuple]:
"""Discover external checks registered via entry points for a provider.
External plugins follow the same layout as built-ins:
`{plugin_root}.services.{service}.{check}.{check}`
When `service` is provided, only entry points whose dotted path contains
`.services.{service}.` are included mirroring how built-in discovery
filters by the `prowler.providers.{provider}.services.{service}` package.
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:
if service and f".services.{service}." not in ep.value:
continue
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(
@@ -49,55 +15,29 @@ def recover_checks_from_provider(
Returns a list of tuples with the following format (check_name, check_path)
"""
try:
# Bypass check loading for tool-wrapper providers — they delegate
# scanning to an external tool and have no checks to recover.
# Single source of truth: combines the EXTERNAL_TOOL_PROVIDERS
# frozenset (built-ins) with the per-provider `is_external_tool_provider`
# class attribute (so external plug-ins opt in via the contract).
if is_tool_wrapper_provider(provider):
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
return []
checks = []
# Built-in checks from prowler.providers.{provider}.services. Gate
# the built-in branch on `is_builtin_provider(provider)` — calling
# `find_spec` directly on `prowler.providers.{provider}.services`
# would propagate `ModuleNotFoundError` when the parent package
# `prowler.providers.{provider}` does not exist (i.e. the provider
# is external), instead of returning None. The leaf helper
# encapsulates the safe lookup, so we only run the built-in
# discovery when the provider actually ships with the SDK; for
# external providers we go straight to entry points.
if is_builtin_provider(provider):
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)
# External checks registered via entry points — always consulted, with
# optional service filter. Previously gated by `if not service:`, which
# prevented external providers from being usable with --service.
checks.extend(_recover_ep_checks(provider, service))
# A service was requested but nothing matched in either built-ins or
# entry points — surface this as a clear error instead of silently
# returning an empty list.
if service and not checks:
logger.critical(
f"Service '{service}' was not found for the '{provider}' provider "
f"(neither as a built-in nor via external entry points)."
)
sys.exit(1)
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)
except Exception as e:
logger.critical(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}")
sys.exit(1)
@@ -124,9 +64,8 @@ def recover_checks_from_service(service_list: list, provider: str) -> set:
Returns a set of checks from the given services
"""
try:
# Bypass check loading for tool-wrapper providers — symmetric with
# `recover_checks_from_provider` above, using the same source of truth.
if is_tool_wrapper_provider(provider):
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
return set()
checks = set()
+7 -52
View File
@@ -20,61 +20,19 @@ from prowler.providers.common.arguments import (
validate_provider_arguments,
validate_sarif_usage,
)
from prowler.providers.common.provider import Provider
class ProwlerArgumentParser:
# Set the default parser
def __init__(self):
# Discover any providers not in the hardcoded list below
# TODO - First step to support current providers and the new external provider implementation
known_providers = {
"aws",
"azure",
"gcp",
"kubernetes",
"m365",
"github",
"googleworkspace",
"cloudflare",
"oraclecloud",
"openstack",
"alibabacloud",
"iac",
"llm",
"image",
"nhn",
"mongodbatlas",
"vercel",
"okta",
"scaleway",
"stackit",
}
all_providers = set(Provider.get_available_providers())
new_providers = sorted(all_providers - known_providers)
# Build extra strings for dynamically discovered providers
extra_providers_csv = ""
extra_providers_text = ""
if new_providers:
providers_help = Provider.get_providers_help_text()
extra_providers_csv = "," + ",".join(new_providers)
extra_lines = []
for name in new_providers:
help_text = providers_help.get(name, "")
if help_text:
extra_lines.append(f" {name:<20}{help_text}")
if extra_lines:
extra_providers_text = "\n" + "\n".join(extra_lines)
# CLI Arguments
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...",
epilog=f"""
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ...",
epilog="""
Available Cloud Providers:
{{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}}
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -94,14 +52,13 @@ Available Cloud Providers:
nhn NHN Provider (Unofficial)
mongodbatlas MongoDB Atlas Provider
scaleway Scaleway Provider
vercel Vercel Provider{extra_providers_text}
vercel Vercel Provider
Available components:
dashboard Local dashboard
To see the different available options on a specific component, run:
prowler {{provider|dashboard}} -h|--help
prowler {provider|dashboard} -h|--help
Detailed documentation at https://docs.prowler.com
""",
@@ -160,10 +117,8 @@ Detailed documentation at https://docs.prowler.com
and (sys.argv[1] not in ("-v", "--version"))
):
# Since the provider is always the second argument, we are checking if
# a flag is supplied. Use startswith("-") instead of "in" to avoid
# matching external provider names that contain hyphens
# (e.g. "local-acme-snowflake").
if sys.argv[1].startswith("-"):
# a flag, starting by "-", is supplied
if "-" in sys.argv[1]:
sys.argv = self.__set_default_provider__(sys.argv)
# Provider aliases mapping
+39 -67
View File
@@ -10,6 +10,7 @@ from prowler.lib.outputs.compliance.cis.cis import get_cis_table
from prowler.lib.outputs.compliance.compliance_check import ( # noqa: F401 - re-export for backward compatibility
get_check_compliance,
)
from prowler.lib.outputs.compliance.csa.csa import get_csa_table
from prowler.lib.outputs.compliance.ens.ens import get_ens_table
from prowler.lib.outputs.compliance.generic.generic_table import (
get_generic_compliance_table,
@@ -32,28 +33,24 @@ def process_universal_compliance_frameworks(
output_filename: str,
provider: str,
generated_outputs: dict,
from_cli: bool = True,
is_last: bool = True,
) -> set:
"""Process universal compliance frameworks, generating CSV and OCSF outputs.
For each framework in *input_compliance_frameworks* that exists in
*universal_frameworks* and has an ``outputs.table_config``, this function
writes both a CSV (``UniversalComplianceOutput``) and an OCSF JSON
(``OCSFComplianceOutput``) file. OCSF is always generated regardless of
*universal_frameworks* and has an outputs.table_config, this function
creates both a CSV (UniversalComplianceOutput) and an OCSF JSON
(OCSFComplianceOutput) file. OCSF is always generated regardless of
the user's ``--output-formats`` flag.
Streaming-aware: writers are tracked via ``generated_outputs["compliance"]``
keyed by ``file_path``. On the first call per framework a new writer is
created and emits both findings and manual requirements; subsequent calls
reuse the writer, transform only the new ``finding_outputs`` (manual
requirements are not re-emitted), and append to the open file. Set
``from_cli=False`` and ``is_last=False`` for intermediate batches; pass
``is_last=True`` on the final batch to close the file (OCSF is also
finalized as a valid JSON array).
The function is idempotent: it tracks already-created writers via
``generated_outputs["compliance"]`` keyed by ``file_path``. If invoked
again for the same framework (e.g. once per streaming batch), it
reuses the existing writer instead of recreating it. This guarantees
one output writer per framework for the whole execution and keeps
the OCSF JSON array valid across multiple calls.
Returns the set of framework names processed so the caller can subtract
them from the legacy per-provider output loop.
Returns the set of framework names that were processed so the caller
can remove them before entering the legacy per-provider output loop.
"""
from prowler.lib.outputs.compliance.universal.ocsf_compliance import (
OCSFComplianceOutput,
@@ -68,13 +65,6 @@ def process_universal_compliance_frameworks(
if isinstance(out, (UniversalComplianceOutput, OCSFComplianceOutput))
}
def _flush(writer, framework, label, is_new):
if not is_new:
writer._transform(finding_outputs, framework, label, include_manual=False)
writer.close_file = is_last
writer.batch_write_data_to_file()
writer._data.clear()
processed = set()
for compliance_name in input_compliance_frameworks:
if not (
@@ -85,46 +75,37 @@ def process_universal_compliance_frameworks(
continue
fw = universal_frameworks[compliance_name]
compliance_label = (
fw.framework + "-" + fw.version if fw.version else fw.framework
)
# CSV output
csv_path = (
f"{output_directory}/compliance/" f"{output_filename}_{compliance_name}.csv"
)
csv_writer = existing_writers.get(csv_path)
csv_is_new = csv_writer is None
if csv_is_new:
csv_writer = UniversalComplianceOutput(
if csv_path not in existing_writers:
output = UniversalComplianceOutput(
findings=finding_outputs,
framework=fw,
file_path=csv_path,
from_cli=from_cli,
provider=provider,
)
generated_outputs["compliance"].append(csv_writer)
existing_writers[csv_path] = csv_writer
_flush(csv_writer, fw, compliance_label, csv_is_new)
generated_outputs["compliance"].append(output)
existing_writers[csv_path] = output
output.batch_write_data_to_file()
# OCSF output (always generated for universal frameworks)
ocsf_path = (
f"{output_directory}/compliance/"
f"{output_filename}_{compliance_name}.ocsf.json"
)
ocsf_writer = existing_writers.get(ocsf_path)
ocsf_is_new = ocsf_writer is None
if ocsf_is_new:
ocsf_writer = OCSFComplianceOutput(
if ocsf_path not in existing_writers:
ocsf_output = OCSFComplianceOutput(
findings=finding_outputs,
framework=fw,
file_path=ocsf_path,
from_cli=from_cli,
provider=provider,
)
generated_outputs["compliance"].append(ocsf_writer)
existing_writers[ocsf_path] = ocsf_writer
_flush(ocsf_writer, fw, compliance_label, ocsf_is_new)
generated_outputs["compliance"].append(ocsf_output)
existing_writers[ocsf_path] = ocsf_output
ocsf_output.batch_write_data_to_file()
processed.add(compliance_name)
@@ -225,6 +206,15 @@ def display_compliance_table(
output_directory,
compliance_overview,
)
elif compliance_framework.startswith("csa_ccm_"):
get_csa_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
elif compliance_framework.startswith("c5_"):
get_c5_table(
findings,
@@ -253,32 +243,14 @@ def display_compliance_table(
compliance_overview,
)
else:
# Try provider-specific table first, fall back to generic
from prowler.providers.common.provider import Provider
provider = Provider.get_global_provider()
handled = False
if provider is not None:
try:
handled = provider.display_compliance_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
except NotImplementedError:
handled = False
if not handled:
get_generic_compliance_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
get_generic_compliance_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
+101
View File
@@ -0,0 +1,101 @@
from colorama import Fore, Style
from tabulate import tabulate
from prowler.config.config import orange_color
def get_csa_table(
findings: list,
bulk_checks_metadata: dict,
compliance_framework: str,
output_filename: str,
output_directory: str,
compliance_overview: bool,
):
section_table = {
"Provider": [],
"Section": [],
"Status": [],
"Muted": [],
}
pass_count = []
fail_count = []
muted_count = []
sections = {}
for index, finding in enumerate(findings):
check = bulk_checks_metadata[finding.check_metadata.CheckID]
check_compliances = check.Compliance
for compliance in check_compliances:
if (
compliance.Framework == "CSA-CCM"
and compliance.Version in compliance_framework
):
for requirement in compliance.Requirements:
for attribute in requirement.Attributes:
section = attribute.Section
if section not in sections:
sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0}
if finding.muted:
if index not in muted_count:
muted_count.append(index)
sections[section]["Muted"] += 1
else:
if finding.status == "FAIL" and index not in fail_count:
fail_count.append(index)
sections[section]["FAIL"] += 1
elif finding.status == "PASS" and index not in pass_count:
pass_count.append(index)
sections[section]["PASS"] += 1
sections = dict(sorted(sections.items()))
for section in sections:
section_table["Provider"].append(compliance.Provider)
section_table["Section"].append(section)
if sections[section]["FAIL"] > 0:
section_table["Status"].append(
f"{Fore.RED}FAIL({sections[section]['FAIL']}){Style.RESET_ALL}"
)
else:
if sections[section]["PASS"] > 0:
section_table["Status"].append(
f"{Fore.GREEN}PASS({sections[section]['PASS']}){Style.RESET_ALL}"
)
else:
section_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}")
section_table["Muted"].append(
f"{orange_color}{sections[section]['Muted']}{Style.RESET_ALL}"
)
if (
len(fail_count) + len(pass_count) + len(muted_count) > 1
): # If there are no resources, don't print the compliance table
print(
f"\nCompliance Status of {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Framework:"
)
total_findings_count = len(fail_count) + len(pass_count) + len(muted_count)
overview_table = [
[
f"{Fore.RED}{round(len(fail_count) / total_findings_count * 100, 2)}% ({len(fail_count)}) FAIL{Style.RESET_ALL}",
f"{Fore.GREEN}{round(len(pass_count) / total_findings_count * 100, 2)}% ({len(pass_count)}) PASS{Style.RESET_ALL}",
f"{orange_color}{round(len(muted_count) / total_findings_count * 100, 2)}% ({len(muted_count)}) MUTED{Style.RESET_ALL}",
]
]
print(tabulate(overview_table, tablefmt="rounded_grid"))
if not compliance_overview:
if len(fail_count) > 0 and len(section_table["Section"]) > 0:
print(
f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:"
)
print(
tabulate(
section_table,
tablefmt="rounded_grid",
headers="keys",
)
)
print(f"\nDetailed results of {compliance_framework.upper()} are in:")
print(
f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework}.csv\n"
)
@@ -0,0 +1,95 @@
from prowler.config.config import timestamp
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.compliance.csa.models import AlibabaCloudCSAModel
from prowler.lib.outputs.finding import Finding
class AlibabaCloudCSA(ComplianceOutput):
"""
This class represents the Alibaba Cloud CSA compliance output.
Attributes:
- _data (list): A list to store transformed data from findings.
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into Alibaba Cloud CSA compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
compliance_name: str,
) -> None:
"""
Transforms a list of findings into Alibaba Cloud CSA compliance format.
Parameters:
- findings (list): A list of findings.
- compliance (Compliance): A compliance model.
- compliance_name (str): The name of the compliance model.
Returns:
- None
"""
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = AlibabaCloudCSAModel(
Provider=finding.provider,
Description=compliance.Description,
AccountId=finding.account_uid,
Region=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = AlibabaCloudCSAModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
AccountId="",
Region="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
@@ -0,0 +1,95 @@
from prowler.config.config import timestamp
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.compliance.csa.models import AWSCSAModel
from prowler.lib.outputs.finding import Finding
class AWSCSA(ComplianceOutput):
"""
This class represents the AWS CSA compliance output.
Attributes:
- _data (list): A list to store transformed data from findings.
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into AWS CSA compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
compliance_name: str,
) -> None:
"""
Transforms a list of findings into AWS CSA compliance format.
Parameters:
- findings (list): A list of findings.
- compliance (Compliance): A compliance model.
- compliance_name (str): The name of the compliance model.
Returns:
- None
"""
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = AWSCSAModel(
Provider=finding.provider,
Description=compliance.Description,
AccountId=finding.account_uid,
Region=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = AWSCSAModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
AccountId="",
Region="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
@@ -0,0 +1,95 @@
from prowler.config.config import timestamp
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.compliance.csa.models import AzureCSAModel
from prowler.lib.outputs.finding import Finding
class AzureCSA(ComplianceOutput):
"""
This class represents the Azure CSA compliance output.
Attributes:
- _data (list): A list to store transformed data from findings.
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into Azure CSA compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
compliance_name: str,
) -> None:
"""
Transforms a list of findings into Azure CSA compliance format.
Parameters:
- findings (list): A list of findings.
- compliance (Compliance): A compliance model.
- compliance_name (str): The name of the compliance model.
Returns:
- None
"""
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = AzureCSAModel(
Provider=finding.provider,
Description=compliance.Description,
SubscriptionId=finding.account_uid,
Location=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = AzureCSAModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
SubscriptionId="",
Location="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
@@ -0,0 +1,95 @@
from prowler.config.config import timestamp
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.compliance.csa.models import GCPCSAModel
from prowler.lib.outputs.finding import Finding
class GCPCSA(ComplianceOutput):
"""
This class represents the GCP CSA compliance output.
Attributes:
- _data (list): A list to store transformed data from findings.
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into GCP CSA compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
compliance_name: str,
) -> None:
"""
Transforms a list of findings into GCP CSA compliance format.
Parameters:
- findings (list): A list of findings.
- compliance (Compliance): A compliance model.
- compliance_name (str): The name of the compliance model.
Returns:
- None
"""
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = GCPCSAModel(
Provider=finding.provider,
Description=compliance.Description,
ProjectId=finding.account_uid,
Location=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = GCPCSAModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
ProjectId="",
Location="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
@@ -0,0 +1,95 @@
from prowler.config.config import timestamp
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.compliance.csa.models import OracleCloudCSAModel
from prowler.lib.outputs.finding import Finding
class OracleCloudCSA(ComplianceOutput):
"""
This class represents the OracleCloud CSA compliance output.
Attributes:
- _data (list): A list to store transformed data from findings.
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into OracleCloud CSA compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
compliance_name: str,
) -> None:
"""
Transforms a list of findings into OracleCloud CSA compliance format.
Parameters:
- findings (list): A list of findings.
- compliance (Compliance): A compliance model.
- compliance_name (str): The name of the compliance model.
Returns:
- None
"""
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = OracleCloudCSAModel(
Provider=finding.provider,
Description=compliance.Description,
TenancyId=finding.account_uid,
Region=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = OracleCloudCSAModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
TenancyId="",
Region="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_CCMLite=attribute.CCMLite,
Requirements_Attributes_IaaS=attribute.IaaS,
Requirements_Attributes_PaaS=attribute.PaaS,
Requirements_Attributes_SaaS=attribute.SaaS,
Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
@@ -0,0 +1,146 @@
from pydantic.v1 import BaseModel
class AWSCSAModel(BaseModel):
"""
AWSCSAModel generates a finding's output in CSV CSA format for AWS.
"""
Provider: str
Description: str
AccountId: str
Region: str
AssessmentDate: str
Requirements_Id: str
Requirements_Description: str
Requirements_Name: str
Requirements_Attributes_Section: str
Requirements_Attributes_CCMLite: str
Requirements_Attributes_IaaS: str
Requirements_Attributes_PaaS: str
Requirements_Attributes_SaaS: str
Requirements_Attributes_ScopeApplicability: list[dict]
Status: str
StatusExtended: str
ResourceId: str
CheckId: str
Muted: bool
ResourceName: str
Framework: str
Name: str
class GCPCSAModel(BaseModel):
"""
GCPCSAModel generates a finding's output in CSV CSA format for GCP.
"""
Provider: str
Description: str
ProjectId: str
Location: str
AssessmentDate: str
Requirements_Id: str
Requirements_Description: str
Requirements_Name: str
Requirements_Attributes_Section: str
Requirements_Attributes_CCMLite: str
Requirements_Attributes_IaaS: str
Requirements_Attributes_PaaS: str
Requirements_Attributes_SaaS: str
Requirements_Attributes_ScopeApplicability: list[dict]
Status: str
StatusExtended: str
ResourceId: str
CheckId: str
Muted: bool
ResourceName: str
Framework: str
Name: str
class OracleCloudCSAModel(BaseModel):
"""
OracleCloudCSAModel generates a finding's output in CSV CSA format for OracleCloud.
"""
Provider: str
Description: str
TenancyId: str
Region: str
AssessmentDate: str
Requirements_Id: str
Requirements_Description: str
Requirements_Name: str
Requirements_Attributes_Section: str
Requirements_Attributes_CCMLite: str
Requirements_Attributes_IaaS: str
Requirements_Attributes_PaaS: str
Requirements_Attributes_SaaS: str
Requirements_Attributes_ScopeApplicability: list[dict]
Status: str
StatusExtended: str
ResourceId: str
CheckId: str
Muted: bool
ResourceName: str
Framework: str
Name: str
class AlibabaCloudCSAModel(BaseModel):
"""
AlibabaCloudCSAModel generates a finding's output in CSV CSA format for Alibaba Cloud.
"""
Provider: str
Description: str
AccountId: str
Region: str
AssessmentDate: str
Requirements_Id: str
Requirements_Description: str
Requirements_Name: str
Requirements_Attributes_Section: str
Requirements_Attributes_CCMLite: str
Requirements_Attributes_IaaS: str
Requirements_Attributes_PaaS: str
Requirements_Attributes_SaaS: str
Requirements_Attributes_ScopeApplicability: list[dict]
Status: str
StatusExtended: str
ResourceId: str
CheckId: str
Muted: bool
ResourceName: str
Framework: str
Name: str
class AzureCSAModel(BaseModel):
"""
AzureCSAModel generates a finding's output in CSV CSA format for Azure.
"""
Provider: str
Description: str
SubscriptionId: str
Location: str
AssessmentDate: str
Requirements_Id: str
Requirements_Description: str
Requirements_Name: str
Requirements_Attributes_Section: str
Requirements_Attributes_CCMLite: str
Requirements_Attributes_IaaS: str
Requirements_Attributes_PaaS: str
Requirements_Attributes_SaaS: str
Requirements_Attributes_ScopeApplicability: list[dict]
Status: str
StatusExtended: str
ResourceId: str
CheckId: str
Muted: bool
ResourceName: str
Framework: str
Name: str
@@ -34,48 +34,60 @@ class GenericCompliance(ComplianceOutput):
Returns:
- None
"""
def compliance_row(requirement, attribute, finding=None):
# Read attribute fields defensively: GenericCompliance is the
# last-resort renderer for any framework, and provider-specific
# schemas (e.g. CIS, ENS, ISO27001) do not declare the universal
# Section/SubSection/SubGroup/Service/Type/Comment fields.
return GenericComplianceModel(
Provider=(finding.provider if finding else compliance.Provider.lower()),
Description=compliance.Description,
AccountId=finding.account_uid if finding else "",
Region=finding.region if finding else "",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=getattr(attribute, "Section", None),
Requirements_Attributes_SubSection=getattr(
attribute, "SubSection", None
),
Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None),
Requirements_Attributes_Service=getattr(attribute, "Service", None),
Requirements_Attributes_Type=getattr(attribute, "Type", None),
Requirements_Attributes_Comment=getattr(attribute, "Comment", None),
Status=finding.status if finding else "MANUAL",
StatusExtended=(finding.status_extended if finding else "Manual check"),
ResourceId=finding.resource_uid if finding else "manual_check",
ResourceName=finding.resource_name if finding else "Manual check",
CheckId=finding.check_id if finding else "manual",
Muted=finding.muted if finding else False,
Framework=compliance.Framework,
Name=compliance.Name,
)
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
self._data.append(
compliance_row(requirement, attribute, finding)
compliance_row = GenericComplianceModel(
Provider=finding.provider,
Description=compliance.Description,
AccountId=finding.account_uid,
Region=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_SubGroup=attribute.SubGroup,
Requirements_Attributes_Service=attribute.Service,
Requirements_Attributes_Type=attribute.Type,
Requirements_Attributes_Comment=attribute.Comment,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
self._data.append(compliance_row(requirement, attribute))
compliance_row = GenericComplianceModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
AccountId="",
Region="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_SubGroup=attribute.SubGroup,
Requirements_Attributes_Service=attribute.Service,
Requirements_Attributes_Type=attribute.Type,
Requirements_Attributes_Comment=attribute.Comment,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
@@ -79,43 +79,30 @@ def _to_snake_case(name: str) -> str:
return s.lower()
def _build_requirement_attrs(requirement, framework):
"""Build the requirement attributes payload for the unmapped section.
def _build_requirement_attrs(requirement, framework) -> dict:
"""Build a dict with requirement attributes for the unmapped section.
Keys are snake_cased and filtered by ``AttributeMetadata.output_formats.ocsf``
when declared. MITRE-style attrs (``{"_raw_attributes": [...]}``) are
unwrapped into a list of per-entry dicts.
Keys are normalized to snake_case for OCSF consistency.
Only includes attributes whose AttributeMetadata has output_formats.ocsf=True.
When no metadata is declared, all attributes are included.
"""
requirement_attributes = requirement.attributes
if not requirement_attributes:
attrs = requirement.attributes
if not attrs:
return {}
# Build set of keys allowed for OCSF output
metadata = framework.attributes_metadata
allowed_keys = (
{entry.key for entry in metadata if entry.output_formats.ocsf}
if metadata
else None
)
if metadata:
ocsf_keys = {m.key for m in metadata if m.output_formats.ocsf}
else:
ocsf_keys = None # No metadata → include all
def _to_snake_case_dict(entry: dict) -> dict:
return {
_to_snake_case(key): value
for key, value in entry.items()
if allowed_keys is None or key in allowed_keys
}
if (
isinstance(requirement_attributes, dict)
and "_raw_attributes" in requirement_attributes
):
raw_entries = requirement_attributes.get("_raw_attributes") or []
return [
_to_snake_case_dict(entry)
for entry in raw_entries
if isinstance(entry, dict)
]
return _to_snake_case_dict(requirement_attributes)
result = {}
for key, value in attrs.items():
if ocsf_keys is not None and key not in ocsf_keys:
continue
result[_to_snake_case(key)] = value
return result
class OCSFComplianceOutput:
@@ -160,14 +147,7 @@ class OCSFComplianceOutput:
findings: List["Finding"],
framework: ComplianceFramework,
compliance_name: str,
include_manual: bool = True,
) -> None:
"""Transform findings into OCSF ComplianceFinding events.
Manual requirements are emitted only when ``include_manual=True``. The
caller must pass ``False`` for subsequent streaming batches so manual
events are not duplicated.
"""
# Build check -> requirements map
check_req_map = {}
for req in framework.requirements:
@@ -190,9 +170,6 @@ class OCSFComplianceOutput:
if cf:
self._data.append(cf)
if not include_manual:
return
# Manual requirements (no checks or empty for current provider)
for req in framework.requirements:
checks = req.checks
@@ -198,15 +198,8 @@ class UniversalComplianceOutput:
findings: list["Finding"],
framework: ComplianceFramework,
compliance_name: str,
include_manual: bool = True,
) -> None:
"""Transform findings into universal compliance CSV rows.
Manual requirements (no checks or empty for current provider) are
emitted only when ``include_manual=True``. When the writer is reused
across streaming batches, the caller should pass ``False`` after the
first batch so manual rows are not duplicated.
"""
"""Transform findings into universal compliance CSV rows."""
# Build check -> requirements map (filtered by provider for dict checks)
check_req_map = {}
for req in framework.requirements:
@@ -235,9 +228,6 @@ class UniversalComplianceOutput:
except Exception as e:
logger.debug(f"Skipping row for {req.id}: {e}")
if not include_manual:
return
# Manual requirements (no checks or empty dict)
for req in framework.requirements:
checks = req.checks
-5
View File
@@ -517,11 +517,6 @@ class Finding(BaseModel):
check_output, "fixed_version", ""
)
else:
# Dynamic fallback: any external/custom provider
provider_data = provider.get_finding_output_data(check_output)
output_data.update(provider_data)
# check_output Unique ID
# TODO: move this to a function
# TODO: in Azure, GCP and K8s there are findings without resource_name
+5 -7
View File
@@ -1608,13 +1608,11 @@ class HTML(Output):
# Azure_provider --> azure
# Kubernetes_provider --> kubernetes
# Try static method first, fall back to provider method
method_name = f"get_{provider.type}_assessment_summary"
if hasattr(HTML, method_name):
return getattr(HTML, method_name)(provider)
else:
# Dynamic fallback: any external/custom provider
return provider.get_html_assessment_summary()
# Dynamically get the Provider quick inventory handler
provider_html_assessment_summary_function = (
f"get_{provider.type}_assessment_summary"
)
return getattr(HTML, provider_html_assessment_summary_function)(provider)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
+1 -15
View File
@@ -229,9 +229,7 @@ class MarkdownToADFConverter:
return node
def _paragraph_with_text(self, text: str) -> Dict:
# ADF forbids empty text nodes; emit an empty paragraph instead.
content = [self._create_text_node(text, None)] if text else []
return {"type": "paragraph", "content": content}
return {"type": "paragraph", "content": [self._create_text_node(text, None)]}
@staticmethod
def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None:
@@ -1120,18 +1118,6 @@ class Jira:
tenant_info: str = "",
) -> dict:
# ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT.
def _safe(value: str) -> str:
return value if (value and value.strip()) else "-"
check_id = _safe(check_id)
check_title = _safe(check_title)
status_extended = _safe(status_extended)
provider = _safe(provider)
region = _safe(region)
resource_uid = _safe(resource_uid)
resource_name = _safe(resource_name)
table_rows = [
{
"type": "tableRow",
-8
View File
@@ -227,10 +227,6 @@ class OCSF(Output):
json_output = finding.json(exclude_none=True, indent=4)
self._file_descriptor.write(json_output)
self._file_descriptor.write(",")
except OSError:
# I/O errors (e.g. ENOSPC) are not recoverable per finding:
# fail fast instead of logging once per finding.
raise
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -243,10 +239,6 @@ class OCSF(Output):
self._file_descriptor.truncate()
self._file_descriptor.write("]")
self._file_descriptor.close()
except OSError:
# Propagate unrecoverable I/O errors (e.g. ENOSPC) so the caller can
# fail fast instead of producing a corrupt output file.
raise
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+22 -36
View File
@@ -7,52 +7,45 @@ from prowler.lib.outputs.common import Status
from prowler.lib.outputs.finding import Finding
def stdout_report(finding, color, verbose, status, fix, provider=None):
def stdout_report(finding, color, verbose, status, fix):
if finding.check_metadata.Provider == "aws":
details = finding.region
elif finding.check_metadata.Provider == "azure":
if finding.check_metadata.Provider == "azure":
details = finding.location
elif finding.check_metadata.Provider == "gcp":
if finding.check_metadata.Provider == "gcp":
details = finding.location.lower()
elif finding.check_metadata.Provider == "kubernetes":
if finding.check_metadata.Provider == "kubernetes":
details = finding.namespace.lower()
elif finding.check_metadata.Provider == "github":
if finding.check_metadata.Provider == "github":
details = finding.owner
elif finding.check_metadata.Provider == "m365":
if finding.check_metadata.Provider == "m365":
details = finding.location
elif finding.check_metadata.Provider == "mongodbatlas":
if finding.check_metadata.Provider == "mongodbatlas":
details = finding.location
elif finding.check_metadata.Provider == "nhn":
if finding.check_metadata.Provider == "nhn":
details = finding.location
elif finding.check_metadata.Provider == "stackit":
if finding.check_metadata.Provider == "stackit":
details = finding.location
elif finding.check_metadata.Provider == "llm":
if finding.check_metadata.Provider == "llm":
details = finding.check_metadata.CheckID
elif finding.check_metadata.Provider == "iac":
if finding.check_metadata.Provider == "iac":
details = finding.check_metadata.CheckID
elif finding.check_metadata.Provider == "oraclecloud":
if finding.check_metadata.Provider == "oraclecloud":
details = finding.region
elif finding.check_metadata.Provider == "alibabacloud":
if finding.check_metadata.Provider == "alibabacloud":
details = finding.region
elif finding.check_metadata.Provider == "openstack":
if finding.check_metadata.Provider == "openstack":
details = finding.region
elif finding.check_metadata.Provider == "cloudflare":
if finding.check_metadata.Provider == "cloudflare":
details = finding.zone_name
elif finding.check_metadata.Provider == "googleworkspace":
if finding.check_metadata.Provider == "googleworkspace":
details = finding.location
elif finding.check_metadata.Provider == "vercel":
if finding.check_metadata.Provider == "vercel":
details = finding.region
elif finding.check_metadata.Provider == "okta":
if finding.check_metadata.Provider == "okta":
details = finding.region
elif finding.check_metadata.Provider == "scaleway":
if finding.check_metadata.Provider == "scaleway":
details = finding.region
else:
# Dynamic fallback: any external/custom provider
if provider is None:
from prowler.providers.common.provider import Provider
provider = Provider.get_global_provider()
details = provider.get_stdout_detail(finding)
if (verbose or fix) and (not status or finding.status in status):
if finding.muted:
@@ -72,15 +65,12 @@ def report(check_findings, provider, output_options):
if hasattr(output_options, "verbose"):
verbose = output_options.verbose
if check_findings:
# TO-DO Generic Function
if provider.type == "aws":
check_findings.sort(key=lambda x: x.region)
elif provider.type == "azure":
if provider.type == "azure":
check_findings.sort(key=lambda x: x.subscription)
else:
# Dynamic fallback: any external/custom provider
sort_key = provider.get_finding_sort_key()
if sort_key and isinstance(sort_key, str):
check_findings.sort(key=lambda x: getattr(x, sort_key, ""))
for finding in check_findings:
# Print findings by stdout
@@ -91,16 +81,12 @@ def report(check_findings, provider, output_options):
if hasattr(output_options, "fixer"):
fixer = output_options.fixer
color = set_report_color(finding.status, finding.muted)
# Pass the local `provider` through so the dynamic else inside
# `stdout_report` does not have to consult the global singleton
# — defeating the whole purpose of the new parameter.
stdout_report(
finding,
color,
verbose,
status,
fixer,
provider=provider,
)
else: # No service resources in the whole account
-3
View File
@@ -121,9 +121,6 @@ def display_summary_table(
elif provider.type == "scaleway":
entity_type = "Organization"
audited_entities = provider.identity.organization_id
else:
# Dynamic fallback: any external/custom provider
entity_type, audited_entities = provider.get_summary_entity()
# Check if there are findings and that they are not all MANUAL
if findings and not all(finding.status == "MANUAL" for finding in findings):
+4 -9
View File
@@ -4,8 +4,8 @@ from types import SimpleNamespace
from typing import Generator
from prowler.lib.check.check import (
_resolve_check_module,
execute,
import_check,
list_services,
update_audit_metadata,
)
@@ -426,14 +426,9 @@ class Scan:
# Recover service from check name
service = get_service_name_from_check_name(check_name)
try:
# Import check module (built-in or entry point) —
# delegates to `_resolve_check_module` so external
# providers registered via entry points are resolved
# correctly (their checks do not live under
# `prowler.providers.{type}.services...`).
lib = _resolve_check_module(
self._provider.type, service, check_name
)
# Import check module
check_module_path = f"prowler.providers.{self._provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -1,40 +0,0 @@
{
"Provider": "aws",
"CheckID": "elbv2_alb_drop_invalid_header_fields_enabled",
"CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"TTPs/Initial Access",
"Effects/Data Exposure"
],
"ServiceName": "elbv2",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsElbv2LoadBalancer",
"ResourceGroup": "network",
"Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.",
"Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields",
"https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4"
],
"Remediation": {
"Code": {
"CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn <ALB_ARN> --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true",
"NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - <example_subnet_id1>\n - <example_subnet_id2>\n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```",
"Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.",
"Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n load_balancer_type = \"application\"\n subnets = [\"<example_subnet_id1>\", \"<example_subnet_id2>\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```"
},
"Recommendation": {
"Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.",
"Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -1,27 +0,0 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client
class elbv2_alb_drop_invalid_header_fields_enabled(Check):
def execute(self):
findings = []
for lb in elbv2_client.loadbalancersv2.values():
if lb.type == "application":
report = Check_Report_AWS(
metadata=self.metadata(),
resource=lb,
)
report.status = "PASS"
report.status_extended = (
f"ELBv2 ALB {lb.name} is configured to drop invalid "
"header fields."
)
if lb.drop_invalid_header_fields != "true":
report.status = "FAIL"
report.status_extended = (
f"ELBv2 ALB {lb.name} is not configured to drop "
"invalid header fields."
)
findings.append(report)
return findings
@@ -1,40 +0,0 @@
{
"Provider": "aws",
"CheckID": "sagemaker_models_monitor_enabled",
"CheckTitle": "Amazon SageMaker has a monitoring schedule scheduled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "sagemaker",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "**SageMaker Models Monitor** detects data drift, model quality issues, and bias drift in production.",
"Risk": "Without an **active monitoring schedule**, data drift, model quality issues, and bias drift go undetected, so **model quality degrades silently** while downstream decisions such as fraud detection, access control, and pricing keep relying on a degrading model.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/sagemaker/latest/dg/model-monitor.html",
"https://docs.aws.amazon.com/sagemaker/latest/dg/model-monitor-scheduling.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable **Amazon SageMaker Model Monitor** and keep at least one **monitoring schedule** in the `Scheduled` state so data quality, model quality, and bias drift are continuously evaluated against a baseline.",
"Url": "https://hub.prowler.com/check/sagemaker_models_monitor_enabled"
}
},
"Categories": [
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -1,22 +0,0 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client
class sagemaker_models_monitor_enabled(Check):
def execute(self):
findings = []
for monitoring_schedule in sagemaker_client.sagemaker_monitoring_schedules:
report = Check_Report_AWS(
metadata=self.metadata(), resource=monitoring_schedule
)
if monitoring_schedule.is_scheduled:
report.status = "PASS"
report.status_extended = f"SageMaker monitoring schedule {monitoring_schedule.name} is enabled in region {monitoring_schedule.region}."
elif not monitoring_schedule.has_schedules:
report.status = "FAIL"
report.status_extended = f"No SageMaker monitoring schedules found in region {monitoring_schedule.region}."
else:
report.status = "FAIL"
report.status_extended = f"No active SageMaker monitoring schedule in region {monitoring_schedule.region}; existing schedules are not in Scheduled status."
findings.append(report)
return findings
@@ -18,7 +18,6 @@ class SageMaker(AWSService):
self.sagemaker_domains = []
self.endpoint_configs = {}
self.sagemaker_model_registries = []
self.sagemaker_monitoring_schedules = []
# Retrieve resources concurrently
self.__threading_call__(self._list_notebook_instances)
@@ -27,7 +26,6 @@ class SageMaker(AWSService):
self.__threading_call__(self._list_endpoint_configs)
self.__threading_call__(self._list_domains)
self.__threading_call__(self._list_model_package_groups)
self.__threading_call__(self._list_monitoring_schedules)
# Describe resources concurrently
self.__threading_call__(self._describe_model, self.sagemaker_models)
@@ -379,46 +377,6 @@ class SageMaker(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_monitoring_schedules(self, regional_client):
logger.info("SageMaker - listing monitoring schedules...")
name = "SageMaker Monitoring Schedules"
arn = self.get_unknown_arn(
region=regional_client.region,
resource_type="monitoring-schedule",
)
has_schedules = False
is_scheduled = False
try:
paginator = regional_client.get_paginator("list_monitoring_schedules")
for page in paginator.paginate():
for schedule in page["MonitoringScheduleSummaries"]:
if not self.audit_resources or (
is_resource_filtered(
schedule["MonitoringScheduleArn"], self.audit_resources
)
):
has_schedules = True
if schedule["MonitoringScheduleStatus"] == "Scheduled":
is_scheduled = True
name = schedule["MonitoringScheduleName"]
arn = schedule["MonitoringScheduleArn"]
break
if is_scheduled:
break
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.sagemaker_monitoring_schedules.append(
MonitoringSchedule(
name=name,
region=regional_client.region,
arn=arn,
has_schedules=has_schedules,
is_scheduled=is_scheduled,
)
)
class NotebookInstance(BaseModel):
name: str
@@ -483,11 +441,3 @@ class ModelRegistry(BaseModel):
region: str
has_groups: bool = False
has_approved_packages: bool = False
class MonitoringSchedule(BaseModel):
name: str
region: str
arn: str
has_schedules: bool = False
is_scheduled: bool = False
+12 -35
View File
@@ -16,41 +16,18 @@ def init_providers_parser(self):
# We need to call the arguments parser for each provider
providers = Provider.get_available_providers()
for provider in providers:
# Discriminate built-in vs external upfront via find_spec, so an
# ImportError from a transitive dependency missing inside a built-in
# arguments module surfaces clearly instead of being silently
# re-routed to the entry-point path (which only has external providers).
if Provider.is_builtin(provider):
try:
getattr(
import_module(
f"{providers_path}.{provider}.{provider_arguments_lib_path}"
),
init_provider_arguments_function,
)(self)
except ImportError as e:
logger.critical(
f"Failed to load arguments for built-in provider '{provider}'. "
f"Missing dependency: {e}. "
f"Ensure all required dependencies are installed."
)
logger.debug("Full traceback:", exc_info=True)
sys.exit(1)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
sys.exit(1)
else:
# External provider — init_parser classmethod via entry point
cls = Provider._load_ep_provider(provider)
if cls and hasattr(cls, "init_parser"):
try:
cls.init_parser(self)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
try:
getattr(
import_module(
f"{providers_path}.{provider}.{provider_arguments_lib_path}"
),
init_provider_arguments_function,
)(self)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
sys.exit(1)
def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]:
-29
View File
@@ -1,29 +0,0 @@
"""Leaf helper for built-in provider detection.
Lives in its own module with no imports back into `prowler.lib.check` so
that callers in `prowler.lib.check.*` can ask "is this provider built-in?"
without creating an import cycle through `prowler.providers.common.provider`
(which transitively imports `prowler.config.config` and from there
`prowler.lib.check.compliance_models` / `prowler.lib.check.external_tool_providers`).
Same rationale as `prowler.lib.check.tool_wrapper`: extracting the predicate
to a leaf module is the canonical way to break the cycle in this codebase.
"""
import importlib.util
def is_builtin_provider(provider: str) -> bool:
"""Return True if the provider's own package ships with the SDK.
Wraps `importlib.util.find_spec` in `try/except (ImportError, ValueError)`
because `find_spec` propagates `ModuleNotFoundError` when a parent package
in the dotted path does not exist (instead of returning `None`). The
try/except is what makes the call safe for external providers, whose
package does not live under `prowler.providers.{provider}`.
"""
try:
spec = importlib.util.find_spec(f"prowler.providers.{provider}")
return spec is not None
except (ImportError, ValueError):
return False
-13
View File
@@ -4,7 +4,6 @@ from os.path import isdir
from pydantic.v1 import BaseModel
from prowler.config.config import output_file_timestamp
from prowler.providers.common.provider import Provider
@@ -70,15 +69,3 @@ class Connection:
is_connected: bool = False
error: Exception = None
def default_output_options(provider, arguments, bulk_checks_metadata):
"""Generic OutputOptions fallback for external providers that do not
implement get_output_options, so the run still produces output instead of
aborting. Honors arguments.output_filename and otherwise derives a name
from the provider type."""
output_options = ProviderOutputOptions(arguments, bulk_checks_metadata)
output_options.output_filename = getattr(arguments, "output_filename", None) or (
f"prowler-output-{provider.type}-{output_file_timestamp}"
)
return output_options
+31 -284
View File
@@ -1,6 +1,4 @@
import importlib
import importlib.metadata
import importlib.util
import os
import pkgutil
import sys
@@ -138,106 +136,6 @@ class Provider(ABC):
"""
return set()
# --- Dynamic provider contract methods (not @abstractmethod for incremental migration) ---
_cli_help_text: str = ""
@classmethod
def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider":
"""Instantiate the provider from CLI arguments and return the instance.
The caller wires the returned instance into the global provider slot
via Provider.set_global_provider(). Implementations that already call
set_global_provider(self) from __init__ are also supported the call
site tolerates a None return in that case.
"""
raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()")
def get_output_options(self, arguments, _bulk_checks_metadata):
"""Create the provider-specific OutputOptions."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_output_options()"
)
def get_stdout_detail(self, _finding) -> str:
"""Return the detail string for stdout reporting (region, location, etc.)."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_stdout_detail()"
)
def get_finding_sort_key(self) -> Optional[str]:
"""Return the attribute name to sort findings by, or None for no sorting."""
return None
def get_summary_entity(self) -> tuple:
"""Return (entity_type, audited_entities) for the summary table."""
return (self.type, getattr(self.identity, "account_id", ""))
def get_finding_output_data(self, _check_output) -> dict:
"""Return provider-specific fields for Finding.generate_output()."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_finding_output_data()"
)
def get_html_assessment_summary(self) -> str:
"""Return the HTML assessment summary card for this provider."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_html_assessment_summary()"
)
def generate_compliance_output(
self,
_findings,
_bulk_compliance_frameworks,
_input_compliance_frameworks,
_output_options,
_generated_outputs,
) -> None:
"""Generate compliance CSV output for this provider's frameworks."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented generate_compliance_output()"
)
def get_mutelist_finding_args(self) -> dict:
"""Return extra kwargs for mutelist.is_finding_muted() besides 'finding'.
External providers must return a dict with the identity key their
Mutelist subclass expects, e.g. ``{"account_id": self.identity.account_id}``.
The ``finding`` kwarg is added automatically by the caller.
"""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_mutelist_finding_args()"
)
def display_compliance_table(
self,
_findings: list,
_bulk_checks_metadata: dict,
_compliance_framework: str,
_output_filename: str,
_output_directory: str,
_compliance_overview: bool,
) -> bool:
"""Render a custom compliance table in the terminal.
External providers can override this to display a detailed
compliance table (e.g., per-section breakdown). Return True
if the table was rendered, False to fall back to the generic table.
"""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented display_compliance_table()"
)
# Class-level flag: True for providers that delegate scanning to an external
# tool (e.g. Trivy, promptfoo) and bypass standard check/service loading and
# metadata validation. Subclasses override as `is_external_tool_provider = True`.
# Kept as a class attribute (not a property) so it can be read from the class
# without instantiation — the metadata validators in lib.check.models need to
# decide whether to relax validation before any provider instance exists.
is_external_tool_provider: bool = False
# --- End dynamic provider contract methods ---
@staticmethod
def get_excluded_regions_from_env() -> set:
"""Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable.
@@ -261,74 +159,20 @@ class Provider(ABC):
@staticmethod
def init_global_provider(arguments: Namespace) -> None:
try:
# Discriminate built-in vs external upfront via find_spec, so an
# ImportError from a transitive dependency missing inside a
# built-in's own import chain surfaces clearly instead of being
# silently re-routed to the entry-point path.
provider_class = None
if Provider.is_builtin(arguments.provider):
# Built-in wins on provider-name collision. Plug-ins are
# first-class extenders (they can register new provider
# names) but cannot override existing built-ins — a security
# tool prefers fail-loud predictability over silent
# overrides. Surface the override so the user knows their
# plug-in is being ignored and can rename it.
# Match by name only — never ep.load() a shadowing plug-in.
if any(
ep.name == arguments.provider
for ep in importlib.metadata.entry_points(group="prowler.providers")
):
logger.warning(
f"Plug-in provider '{arguments.provider}' registered "
f"via entry points is being IGNORED — a built-in with "
f"the same name exists. To use your plug-in, register "
f"it under a different name."
)
provider_class_path = f"{providers_path}.{arguments.provider}.{arguments.provider}_provider"
provider_class_name = f"{arguments.provider.capitalize()}Provider"
try:
provider_class = getattr(
import_module(provider_class_path), provider_class_name
)
except ImportError as e:
logger.critical(
f"Failed to load built-in provider '{arguments.provider}'. "
f"Missing dependency: {e}. "
f"Ensure all required dependencies are installed."
)
logger.debug("Full traceback:", exc_info=True)
sys.exit(1)
except AttributeError:
# Module exists but doesn't define the expected class —
# treat as external and try entry points.
provider_class = Provider._load_ep_provider(arguments.provider)
else:
provider_class = Provider._load_ep_provider(arguments.provider)
if provider_class is None:
raise ImportError(
f"Provider '{arguments.provider}' not found as built-in or entry point"
)
# Kept for downstream forks that may extend the dispatch below
# with their own custom built-in branches and reference this name.
# The upstream chain dispatches by `arguments.provider` directly.
provider_class_name = (
f"{arguments.provider.capitalize()}Provider" # noqa: F841
provider_class_path = (
f"{providers_path}.{arguments.provider}.{arguments.provider}_provider"
)
provider_class_name = f"{arguments.provider.capitalize()}Provider"
provider_class = getattr(
import_module(provider_class_path), provider_class_name
)
fixer_config = load_and_validate_config_file(
arguments.provider, arguments.fixer_config
)
# Dispatch by exact provider name (equality, not substring) so
# external plug-ins whose names contain a built-in substring
# (e.g. `awsx`, `azure_gov`, `iac_v2`) cannot be silently routed
# to the wrong built-in branch. Anything that doesn't match a
# built-in falls through to the dynamic else and uses the
# contract's `from_cli_args`.
if not isinstance(Provider._global, provider_class):
if arguments.provider == "aws":
if "aws" in provider_class_name.lower():
excluded_regions = (
set(arguments.excluded_region)
if getattr(arguments, "excluded_region", None)
@@ -352,7 +196,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "azure":
elif "azure" in provider_class_name.lower():
provider_class(
az_cli_auth=arguments.az_cli_auth,
sp_env_auth=arguments.sp_env_auth,
@@ -365,7 +209,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "gcp":
elif "gcp" in provider_class_name.lower():
provider_class(
retries_max_attempts=arguments.gcp_retries_max_attempts,
organization_id=arguments.organization_id,
@@ -379,7 +223,7 @@ class Provider(ABC):
fixer_config=fixer_config,
skip_api_check=arguments.skip_api_check,
)
elif arguments.provider == "kubernetes":
elif "kubernetes" in provider_class_name.lower():
provider_class(
kubeconfig_file=arguments.kubeconfig_file,
context=arguments.context,
@@ -389,7 +233,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "m365":
elif "m365" in provider_class_name.lower():
provider_class(
region=arguments.region,
config_path=arguments.config_file,
@@ -403,7 +247,7 @@ class Provider(ABC):
init_modules=arguments.init_modules,
fixer_config=fixer_config,
)
elif arguments.provider == "nhn":
elif "nhn" in provider_class_name.lower():
provider_class(
username=arguments.nhn_username,
password=arguments.nhn_password,
@@ -412,7 +256,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "stackit":
elif "stackit" in provider_class_name.lower():
provider_class(
project_id=arguments.stackit_project_id,
service_account_key_path=getattr(
@@ -431,7 +275,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "github":
elif "github" in provider_class_name.lower():
orgs = []
repos = []
@@ -463,13 +307,13 @@ class Provider(ABC):
exclude_workflows=getattr(arguments, "exclude_workflows", []),
fixer_config=fixer_config,
)
elif arguments.provider == "googleworkspace":
elif "googleworkspace" in provider_class_name.lower():
provider_class(
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "cloudflare":
elif "cloudflare" in provider_class_name.lower():
provider_class(
filter_zones=arguments.region,
filter_accounts=arguments.account_id,
@@ -477,7 +321,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "iac":
elif "iac" in provider_class_name.lower():
provider_class(
scan_path=arguments.scan_path,
scan_repository_url=arguments.scan_repository_url,
@@ -490,13 +334,13 @@ class Provider(ABC):
oauth_app_token=arguments.oauth_app_token,
provider_uid=arguments.provider_uid,
)
elif arguments.provider == "llm":
elif "llm" in provider_class_name.lower():
provider_class(
max_concurrency=arguments.max_concurrency,
config_path=arguments.config_file,
fixer_config=fixer_config,
)
elif arguments.provider == "image":
elif "image" in provider_class_name.lower():
provider_class(
images=arguments.images,
image_list_file=arguments.image_list_file,
@@ -514,7 +358,7 @@ class Provider(ABC):
registry_insecure=arguments.registry_insecure,
registry_list_images=arguments.registry_list_images,
)
elif arguments.provider == "mongodbatlas":
elif "mongodbatlas" in provider_class_name.lower():
provider_class(
atlas_public_key=arguments.atlas_public_key,
atlas_private_key=arguments.atlas_private_key,
@@ -523,7 +367,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "oraclecloud":
elif "oraclecloud" in provider_class_name.lower():
provider_class(
oci_config_file=arguments.oci_config_file,
profile=arguments.profile,
@@ -534,7 +378,7 @@ class Provider(ABC):
fixer_config=fixer_config,
use_instance_principal=arguments.use_instance_principal,
)
elif arguments.provider == "openstack":
elif "openstack" in provider_class_name.lower():
provider_class(
clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None),
clouds_yaml_content=getattr(
@@ -559,7 +403,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "alibabacloud":
elif "alibabacloud" in provider_class_name.lower():
provider_class(
role_arn=arguments.role_arn,
role_session_name=arguments.role_session_name,
@@ -571,14 +415,14 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "vercel":
elif "vercel" in provider_class_name.lower():
provider_class(
projects=getattr(arguments, "project", None),
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "okta":
elif "okta" in provider_class_name.lower():
provider_class(
okta_org_domain=getattr(arguments, "okta_org_domain", ""),
okta_client_id=getattr(arguments, "okta_client_id", ""),
@@ -591,7 +435,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif arguments.provider == "scaleway":
elif "scaleway" in provider_class_name.lower():
# Credentials are read from the SCW_ACCESS_KEY /
# SCW_SECRET_KEY env vars by the provider itself; there
# are no credential CLI flags to avoid leaking secrets.
@@ -603,18 +447,6 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
else:
# Dynamic fallback: any external/custom provider.
# Honor the from_cli_args type hint (-> Provider): if the
# implementation returns an instance, wire it as the global
# provider here. Implementations that call
# set_global_provider(self) from __init__ return None and
# remain supported (the condition below is a no-op for them).
provider_instance = provider_class.from_cli_args(
arguments, fixer_config
)
if provider_instance is not None:
Provider.set_global_provider(provider_instance)
except TypeError as error:
logger.critical(
@@ -627,102 +459,17 @@ class Provider(ABC):
)
sys.exit(1)
# Cache for entry-point provider classes {name: class}
_ep_providers: dict = {}
@staticmethod
def get_available_providers() -> list[str]:
"""get_available_providers returns a list of the available providers"""
providers = set()
# Built-in providers from local package
providers = []
# Dynamically import the package based on its string path
prowler_providers = importlib.import_module(providers_path)
# Iterate over all modules found in the prowler_providers package
for _, provider, ispkg in pkgutil.iter_modules(prowler_providers.__path__):
if provider != "common" and ispkg:
providers.add(provider)
# External providers registered via entry points
for ep in importlib.metadata.entry_points(group="prowler.providers"):
providers.add(ep.name)
return sorted(providers)
@staticmethod
def is_tool_wrapper_provider(provider: str) -> bool:
"""Return True if the provider delegates scanning to an external tool.
Delegates to `prowler.lib.check.tool_wrapper.is_tool_wrapper_provider`,
the leaf module that holds the actual logic. Kept on `Provider` as a
convenience entry point for callers that already import `Provider`.
"""
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider as _impl
return _impl(provider)
@staticmethod
def is_builtin(provider: str) -> bool:
"""Return True if the provider's own package is importable as a built-in.
Delegates to `prowler.providers.common.builtin.is_builtin_provider`,
the leaf module that holds the actual check. Kept on `Provider` as a
convenience entry point for callers that already import `Provider`.
Call sites in `prowler.lib.check.*` should import from the leaf
directly to avoid the import cycle through this module.
"""
from prowler.providers.common.builtin import is_builtin_provider as _impl
return _impl(provider)
@staticmethod
def _load_ep_provider(name: str):
"""Load an external provider class from entry points, with cache.
Caches both hits and misses so repeated lookups for unknown names do
not re-iterate entry_points(). Symmetric with
tool_wrapper._ep_class_cache.
"""
if name in Provider._ep_providers:
return Provider._ep_providers[name]
for ep in importlib.metadata.entry_points(group="prowler.providers"):
if ep.name == name:
try:
cls = ep.load()
Provider._ep_providers[name] = cls
return cls
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
Provider._ep_providers[name] = None
return None
@staticmethod
def get_providers_help_text() -> dict:
"""Returns a dict of {provider_name: cli_help_text} for all available providers."""
help_text = {}
for name in Provider.get_available_providers():
try:
# Try built-in first
module_path = f"{providers_path}.{name}.{name}_provider"
module = import_module(module_path)
cls = None
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, Provider)
and attr is not Provider
):
cls = attr
break
help_text[name] = getattr(cls, "_cli_help_text", "") if cls else ""
except ImportError:
# External provider — load via entry point
cls = Provider._load_ep_provider(name)
help_text[name] = getattr(cls, "_cli_help_text", "") if cls else ""
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
help_text[name] = ""
return help_text
providers.append(provider)
return providers
@staticmethod
def update_provider_config(audit_config: dict, variable: str, value: str):
@@ -37,7 +37,6 @@ class IAM(GCPService):
display_name=account.get("displayName", ""),
project_id=project_id,
uniqueId=account.get("uniqueId", ""),
disabled=account.get("disabled", False),
)
)
@@ -103,7 +102,6 @@ class ServiceAccount(BaseModel):
keys: list[Key] = []
project_id: str
uniqueId: str
disabled: bool = False
class AccessApproval(GCPService):
@@ -19,12 +19,7 @@ class iam_service_account_unused(Check):
resource_id=account.email,
location=iam_client.region,
)
if account.disabled:
report.status = "PASS"
report.status_extended = (
f"Service Account {account.email} is disabled and cannot be used."
)
elif account.uniqueId in sa_ids_used:
if account.uniqueId in sa_ids_used:
report.status = "PASS"
report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days."
else:
@@ -1,14 +1,14 @@
{
"Provider": "gcp",
"CheckID": "kms_key_rotation_enabled",
"CheckTitle": "KMS key has automatic rotation enabled",
"CheckTitle": "KMS key is rotated at least annually",
"CheckType": [],
"ServiceName": "kms",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "cloudkms.googleapis.com/CryptoKey",
"Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled, regardless of the rotation interval.\n\nThe evaluation reviews each key's rotation settings to confirm that a rotation period is configured so new key versions are created periodically.",
"Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled or a rotation interval `365` days.\n\nThe evaluation reviews each key's rotation settings to confirm periodic creation of new key versions.",
"Risk": "Without timely rotation, a stolen key can decrypt an expanding volume of data, eroding **confidentiality**. Prolonged key lifetimes widen windows for misuse, impact **integrity** of protected workloads, and make emergency rollover harder, risking **availability** disruptions.",
"RelatedUrl": "",
"AdditionalURLs": [
@@ -17,13 +17,13 @@
],
"Remediation": {
"Code": {
"CLI": "gcloud kms keys update <KEY_NAME> --keyring=<KEY_RING> --location=<LOCATION> --rotation-period=<PERIOD> --next-rotation-time=<RFC3339_TIME>",
"CLI": "gcloud kms keys update <KEY_NAME> --keyring=<KEY_RING> --location=<LOCATION> --rotation-period=365d --next-rotation-time=<RFC3339_TIME>",
"NativeIaC": "",
"Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set a Rotation period\n5. Set Next rotation date/time\n6. Click Save",
"Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n key_ring = \"<example_resource_id>\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"7776000s\" # Critical: enables automatic rotation (any period ensures PASS)\n}\n```"
"Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set Rotation period to 365 days or less\n5. Set Next rotation date/time\n6. Click Save",
"Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n key_ring = \"<example_resource_id>\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"31536000s\" # Critical: sets automatic rotation to 365 days (<= 365 ensures PASS)\n}\n```"
},
"Recommendation": {
"Text": "Enable **auto-rotation** for customer-managed keys by configuring a rotation period.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.",
"Text": "Enable **auto-rotation** for customer-managed keys with an interval `365` days.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.",
"Url": "https://hub.prowler.com/check/kms_key_rotation_enabled"
}
},
@@ -31,8 +31,6 @@
"encryption"
],
"DependsOn": [],
"RelatedTo": [
"kms_key_rotation_max_90_days"
],
"RelatedTo": [],
"Notes": ""
}
@@ -1,3 +1,5 @@
import datetime
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.kms.kms_client import kms_client
@@ -7,16 +9,36 @@ class kms_key_rotation_enabled(Check):
findings = []
for key in kms_client.crypto_keys:
report = Check_Report_GCP(metadata=self.metadata(), resource=key)
if key.rotation_period:
report.status = "PASS"
report.status_extended = (
f"Key {key.name} has automatic rotation enabled."
now = datetime.datetime.now()
condition_next_rotation_time = False
if key.next_rotation_time:
try:
next_rotation_time = datetime.datetime.strptime(
key.next_rotation_time, "%Y-%m-%dT%H:%M:%S.%fZ"
)
except ValueError:
next_rotation_time = datetime.datetime.strptime(
key.next_rotation_time, "%Y-%m-%dT%H:%M:%SZ"
)
condition_next_rotation_time = (
abs((next_rotation_time - now).days) <= 90
)
condition_rotation_period = False
if key.rotation_period:
condition_rotation_period = (
int(key.rotation_period[:-1]) // (24 * 3600) <= 90
)
if condition_rotation_period and condition_next_rotation_time:
report.status = "PASS"
report.status_extended = f"Key {key.name} is rotated every 90 days or less and the next rotation time is in less than 90 days."
else:
report.status = "FAIL"
report.status_extended = (
f"Key {key.name} does not have automatic rotation enabled."
)
if condition_rotation_period:
report.status_extended = f"Key {key.name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
elif condition_next_rotation_time:
report.status_extended = f"Key {key.name} is not rotated every 90 days or less but the next rotation time is in less than 90 days."
else:
report.status_extended = f"Key {key.name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
findings.append(report)
return findings

Some files were not shown because too many files have changed in this diff Show More