Compare commits

..

7 Commits

Author SHA1 Message Date
Prowler Bot 21e7f29153 fix(ui): show delete user action only for the current user (#11458)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-03 17:12:55 +02:00
Prowler Bot de51eed96c fix(ui): refine add-provider wizard flow between scans and providers (#11457)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-06-03 16:30:18 +02:00
Prowler Bot 835dbddc6a chore(release): Bump versions to v5.29.2 (#11446)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-03 10:52:45 +02:00
Prowler Bot 103761f146 perf(api): avoid N+1 query loading finding resource tags (#11426)
Co-authored-by: Davidm4r <david.copo@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-02 13:50:56 +02:00
Prowler Bot 0e6268e159 fix(api): clean up scan tmp output failure to avoid disk fill (#11423)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-02 11:58:04 +02:00
Prowler Bot f48984e6a1 chore(release): Bump versions to v5.29.1 (#11417)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-01 18:35:25 +02:00
Prowler Bot 6df80a4890 chore(api): Update prowler dependency to v5.29 for release 5.29.0 (#11414)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-01 16:26:56 +02:00
113 changed files with 39834 additions and 5041 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.2
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+2 -2
View File
@@ -61,12 +61,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/api-codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: '/language:${{ matrix.language }}'
+2 -2
View File
@@ -66,12 +66,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/sdk-codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: '/language:${{ matrix.language }}'
+2 -2
View File
@@ -62,12 +62,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/ui-codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: '/language:${{ matrix.language }}'
-12
View File
@@ -153,8 +153,6 @@ Prowler App offers flexible installation methods tailored to various environment
#### Commands
_macOS/Linux:_
``` console
VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name)
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml"
@@ -163,16 +161,6 @@ curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${V
docker compose up -d
```
_Windows PowerShell:_
``` powershell
$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
docker compose up -d
```
> [!WARNING]
> 🔒 For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
+1 -1
View File
@@ -167,7 +167,7 @@ runs:
- name: Upload SARIF to GitHub Code Scanning
if: always() && inputs.upload-sarif == 'true' && steps.find-sarif.outputs.sarif_path != ''
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
sarif_file: ${{ steps.find-sarif.outputs.sarif_path }}
category: ${{ inputs.sarif-category }}
-19
View File
@@ -2,25 +2,6 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.31.0] (Prowler UNRELEASED)
### 🚀 Added
- Automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: stuck scan and summary tasks are detected and re-run instead of staying pending forever, with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- Jira integration no longer creates duplicate issues on a retried send; findings already ticketed are skipped [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
### 🔄 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)
- A recovered scan rewrites its findings, summaries, attack surface, and compliance data instead of appending to the previous run, so recovery never leaves stale or duplicate materialized rows [(#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)
---
## [1.30.1] (Prowler v5.29.1)
### 🐞 Fixed
+4 -4
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() {
-86
View File
@@ -1,86 +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 `Scan` stays `EXECUTING`,
the `TaskResult` stays `STARTED`, and nothing re-runs it. This page describes the
mechanisms that detect and recover allowlisted idempotent orphans so users never
see a stuck scan and pending-task alerts do not fire.
## 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.
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 scan 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 scan is re-enqueued from scratch.
The re-run is safe because only tasks with proven idempotency are allowlisted.
Scan persistence, for example, clears the scan's prior findings and materialized
summary/compliance rows before re-writing them. Jira sends are allowlisted too:
each finding is reserved in a dispatch table before the external call, so a re-run
skips already-ticketed findings (the worst case is one finding missed if a worker
is hard-killed mid-send, never a duplicate issue). Other external side effects stay
terminal: 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.** Each automatic re-enqueue increments `Scan.recovery_count`.
After `--max-attempts` recoveries (default 3) the scan is marked `FAILED` 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. |
`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.
+2 -2
View File
@@ -43,7 +43,7 @@ dependencies = [
"defusedxml==0.7.1",
"gunicorn==23.0.0",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.29",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -68,7 +68,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.31.0"
version = "1.30.2"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+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,49 +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 (scans are marked FAILED).",
)
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
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,17 +0,0 @@
# Generated by Django 5.1.15 on 2026-05-30 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0093_okta_provider"),
]
operations = [
migrations.AddField(
model_name="scan",
name="recovery_count",
field=models.IntegerField(default=0),
),
]
@@ -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", "0094_scan_recovery_count"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.RunPython(create_periodic_task, delete_periodic_task),
]
@@ -1,64 +0,0 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
import api.rls
class Migration(migrations.Migration):
dependencies = [
("api", "0095_reconcile_orphan_tasks_periodic_task"),
]
operations = [
migrations.CreateModel(
name="JiraIssueDispatch",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("finding_id", models.UUIDField()),
(
"integration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="jira_dispatches",
to="api.integration",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "jira_issue_dispatches",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="jiraissuedispatch",
constraint=models.UniqueConstraint(
fields=("tenant_id", "integration_id", "finding_id"),
name="unique_jira_issue_dispatch",
),
),
migrations.AddConstraint(
model_name="jiraissuedispatch",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_jiraissuedispatch",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
-32
View File
@@ -666,9 +666,6 @@ class Scan(RowLevelSecurityProtectedModel):
state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE)
unique_resource_count = models.IntegerField(default=0)
progress = models.IntegerField(default=0)
# Incremented by the scan-specific orphan-recovery path each time this scan is
# re-pointed to a fresh task; for observability (the retry cap is a Valkey counter).
recovery_count = models.IntegerField(default=0)
scanner_args = models.JSONField(default=dict)
duration = models.IntegerField(null=True, blank=True)
scheduled_at = models.DateTimeField(null=True, blank=True)
@@ -2001,35 +1998,6 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel):
]
class JiraIssueDispatch(RowLevelSecurityProtectedModel):
"""Tracks findings already sent to a Jira integration.
Lets the Jira task be re-run safely (e.g. by orphan recovery): findings with
an existing dispatch row are skipped, so no duplicate issues are created.
"""
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
integration = models.ForeignKey(
Integration, on_delete=models.CASCADE, related_name="jira_dispatches"
)
finding_id = models.UUIDField()
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "jira_issue_dispatches"
constraints = [
models.UniqueConstraint(
fields=["tenant_id", "integration_id", "finding_id"],
name="unique_jira_issue_dispatch",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class SAMLToken(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
+2 -53
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.31.0
version: 1.30.2
description: |-
Prowler API specification.
@@ -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
+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)}"
)
-10
View File
@@ -9560,16 +9560,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
):
+45 -106
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):
@@ -2293,16 +2269,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 +2299,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,
-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"])
@@ -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`:
-9
View File
@@ -11,7 +11,6 @@ from api.db_utils import batch_delete, rls_transaction
from api.models import (
AttackPathsScan,
Finding,
JiraIssueDispatch,
Provider,
ProviderComplianceScore,
Resource,
@@ -81,14 +80,6 @@ def delete_provider(tenant_id: str, pk: str):
deletion_steps = [
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
(
"Jira Issue Dispatches",
JiraIssueDispatch.objects.filter(
finding_id__in=Finding.all_objects.filter(
scan__provider=instance
).values_list("id", flat=True)
),
),
("Findings", Finding.all_objects.filter(scan__provider=instance)),
("Resources", Resource.all_objects.filter(provider=instance)),
("Scans", Scan.all_objects.filter(provider=instance)),
+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,
+51 -100
View File
@@ -9,7 +9,7 @@ from tasks.utils import batched
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction
from api.models import Finding, Integration, JiraIssueDispatch, Provider
from api.models import Finding, Integration, Provider
from api.utils import initialize_prowler_integration, initialize_prowler_provider
from prowler.lib.outputs.asff.asff import ASFF
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
@@ -482,115 +482,66 @@ def send_findings_to_jira(
with rls_transaction(tenant_id):
integration = Integration.objects.get(id=integration_id)
jira_integration = initialize_prowler_integration(integration)
# Idempotency: findings already ticketed for this integration must not be
# sent again on a re-run (e.g. orphan recovery), to avoid duplicate issues
already_sent = {
str(fid)
for fid in JiraIssueDispatch.objects.filter(
integration_id=integration_id, finding_id__in=finding_ids
).values_list("finding_id", flat=True)
}
num_tickets_created = 0
skipped_count = 0
for finding_id in finding_ids:
if str(finding_id) in already_sent:
skipped_count += 1
continue
# Reserve the finding BEFORE the external call. The unique constraint on
# (tenant, integration, finding) makes the dispatch row the single source of
# truth, so a concurrent run or a retry that raced past the bulk pre-check
# cannot create a duplicate issue: created=False means another run already
# claimed it. The reservation is released below if the send does not succeed.
with rls_transaction(tenant_id):
_, created = JiraIssueDispatch.objects.get_or_create(
tenant_id=tenant_id,
integration_id=integration_id,
finding_id=finding_id,
finding_instance = (
Finding.all_objects.select_related("scan__provider")
.prefetch_related("resources")
.get(id=finding_id)
)
if not created:
skipped_count += 1
continue
sent = False
try:
with rls_transaction(tenant_id):
finding_instance = (
Finding.all_objects.select_related("scan__provider")
.prefetch_related("resources")
.get(id=finding_id)
)
# Extract resource information
resource = (
finding_instance.resources.first()
if finding_instance.resources.exists()
else None
)
resource_uid = resource.uid if resource else ""
resource_name = resource.name if resource else ""
resource_tags = {}
if resource and hasattr(resource, "tags"):
resource_tags = resource.get_tags(tenant_id)
# Extract resource information
resource = (
finding_instance.resources.first()
if finding_instance.resources.exists()
else None
)
resource_uid = resource.uid if resource else ""
resource_name = resource.name if resource else ""
resource_tags = {}
if resource and hasattr(resource, "tags"):
resource_tags = resource.get_tags(tenant_id)
# Get region
region = resource.region if resource and resource.region else ""
# Get region
region = resource.region if resource and resource.region else ""
# Extract remediation information from check_metadata
check_metadata = finding_instance.check_metadata
remediation = check_metadata.get("remediation", {})
recommendation = remediation.get("recommendation", {})
remediation_code = remediation.get("code", {})
# Extract remediation information from check_metadata
check_metadata = finding_instance.check_metadata
remediation = check_metadata.get("remediation", {})
recommendation = remediation.get("recommendation", {})
remediation_code = remediation.get("code", {})
# Send the individual finding to Jira
sent = bool(
jira_integration.send_finding(
check_id=finding_instance.check_id,
check_title=check_metadata.get("checktitle", ""),
severity=finding_instance.severity,
status=finding_instance.status,
status_extended=finding_instance.status_extended or "",
provider=finding_instance.scan.provider.provider,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
risk=check_metadata.get("risk", ""),
recommendation_text=recommendation.get("text", ""),
recommendation_url=recommendation.get("url", ""),
remediation_code_native_iac=remediation_code.get(
"nativeiac", ""
),
remediation_code_terraform=remediation_code.get(
"terraform", ""
),
remediation_code_cli=remediation_code.get("cli", ""),
remediation_code_other=remediation_code.get("other", ""),
resource_tags=resource_tags,
compliance=finding_instance.compliance or {},
project_key=project_key,
issue_type=issue_type,
)
)
finally:
if not sent:
# Release the reservation so a later run can retry this finding: it
# was not ticketed (send failed or raised), so the row must not block
# a future legitimate send.
with rls_transaction(tenant_id):
JiraIssueDispatch.objects.filter(
tenant_id=tenant_id,
integration_id=integration_id,
finding_id=finding_id,
).delete()
if sent:
num_tickets_created += 1
else:
logger.error(f"Failed to send finding {finding_id} to Jira")
# Send the individual finding to Jira
result = jira_integration.send_finding(
check_id=finding_instance.check_id,
check_title=check_metadata.get("checktitle", ""),
severity=finding_instance.severity,
status=finding_instance.status,
status_extended=finding_instance.status_extended or "",
provider=finding_instance.scan.provider.provider,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
risk=check_metadata.get("risk", ""),
recommendation_text=recommendation.get("text", ""),
recommendation_url=recommendation.get("url", ""),
remediation_code_native_iac=remediation_code.get("nativeiac", ""),
remediation_code_terraform=remediation_code.get("terraform", ""),
remediation_code_cli=remediation_code.get("cli", ""),
remediation_code_other=remediation_code.get("other", ""),
resource_tags=resource_tags,
compliance=finding_instance.compliance or {},
project_key=project_key,
issue_type=issue_type,
)
if result:
num_tickets_created += 1
else:
logger.error(f"Failed to send finding {finding_id} to Jira")
return {
"created_count": num_tickets_created,
"failed_count": len(finding_ids) - num_tickets_created - skipped_count,
"skipped_count": skipped_count,
"failed_count": len(finding_ids) - num_tickets_created,
}
@@ -1,397 +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)
# Scan tasks are recovered by re-running scan-perform on the EXISTING scan row,
# not by re-enqueuing the original task: re-enqueuing scan-perform-scheduled would
# hit its "a scan is already executing" guard and no-op, leaving the scan stuck.
_SCAN_TASKS = ("scan-perform", "scan-perform-scheduled")
# Tasks with proven idempotency are auto re-enqueued. Scans/summaries clear and
# rewrite their own rows. integration-jira is safe too: each finding is reserved in
# JiraIssueDispatch before the external call, so a re-run skips already-ticketed
# findings (worst case one finding missed on a mid-send crash, never a duplicate).
# Other external side effects stay terminal: integration-s3 rebuilds its upload from
# worker-local files that do not survive a crash, and report/Security Hub recovery is
# out of scope.
REENQUEUEABLE_TASKS = {
*_SCAN_TASKS,
"provider-deletion",
"tenant-deletion",
"scan-summary",
"scan-compliance-overviews",
"scan-provider-compliance-scores",
"scan-daily-severity",
"scan-finding-group-summaries",
"scan-reset-ephemeral-resources",
"integration-jira",
}
# Tasks excluded from generic recovery: 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 = {
"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}
# 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,
)
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"])
attempt = _recovery_attempt_count(name, kwargs_repr, window_hours)
if name not in REENQUEUEABLE_TASKS or attempt > max_attempts:
reason = (
f"{name} is not allowlisted for auto recovery"
if name not in REENQUEUEABLE_TASKS
else f"recovery cap reached ({attempt}/{max_attempts})"
)
_fail_domain_row(task_result.task_id, name, now)
logger.warning(
"Orphan %s (%s) not re-enqueued: %s", task_result.task_id, name, reason
)
return "failed"
# Scan tasks: re-run the EXISTING scan row directly via scan-perform, so the
# scheduled-scan "already executing" guard cannot turn recovery into a no-op.
# Falls through to the generic path only if no scan is linked yet (e.g. a
# scheduled task that died before creating one), where re-running it creates one.
if name in _SCAN_TASKS:
scan = _scan_for_task(task_result.task_id)
if scan is not None:
if not _reenqueue_scan(task_result.task_id, scan):
return "failed"
logger.info(
"Re-enqueued orphaned scan %s (was task %s)",
scan.id,
task_result.task_id,
)
return "recovered"
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,
)
_fail_domain_row(task_result.task_id, name, now)
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"
def _scan_for_task(task_id: str):
"""Return the Scan linked to a Celery task id, or None (read across tenants)."""
from api.db_router import MainRouter
from api.models import Scan
return Scan.all_objects.using(MainRouter.admin_db).filter(task_id=task_id).first()
def _reenqueue_scan(old_task_id: str, scan) -> bool:
"""Re-run an orphaned scan via scan-perform on the existing row.
Pre-provisions the new task linkage (TaskResult + api.Task) and relinks the
Scan before enqueuing, so the FK is valid and a worker can never outrun the DB.
The relink is conditional on the scan still pointing at the old task, so a stale
orphan can never clobber a newer linkage.
"""
from django_celery_results.models import TaskResult
from api.db_utils import rls_transaction
from api.models import Scan
from api.models import Task as APITask
from tasks.tasks import perform_scan_task
tenant_id = str(scan.tenant_id)
new_task_id = str(uuid4())
with rls_transaction(tenant_id):
locked_scan = Scan.all_objects.select_for_update().filter(id=scan.id).first()
if locked_scan is None or str(locked_scan.task_id) != old_task_id:
logger.info(
"Scan %s no longer points at task %s; skipping recovery re-enqueue",
scan.id,
old_task_id,
)
return False
task_result_new, _ = TaskResult.objects.get_or_create(
task_id=new_task_id,
defaults={"status": states.PENDING, "task_name": "scan-perform"},
)
APITask.objects.update_or_create(
id=new_task_id,
tenant_id=tenant_id,
defaults={"task_runner_task": task_result_new},
)
locked_scan.task_id = new_task_id
locked_scan.recovery_count = (locked_scan.recovery_count or 0) + 1
locked_scan.save(update_fields=["task_id", "recovery_count", "updated_at"])
perform_scan_task.apply_async(
kwargs={
"tenant_id": tenant_id,
"scan_id": str(scan.id),
"provider_id": str(scan.provider_id),
},
task_id=new_task_id,
)
return True
def _fail_domain_row(old_task_id: str, name: str, now: datetime) -> None:
"""Mark a scan terminal when its task is capped/denylisted instead of re-run."""
from api.db_utils import rls_transaction
from api.models import Scan, StateChoices
if name in _SCAN_TASKS:
scan = _scan_for_task(old_task_id)
if scan is not None:
with rls_transaction(str(scan.tenant_id)):
Scan.all_objects.filter(id=scan.id, task_id=old_task_id).update(
state=StateChoices.FAILED, completed_at=now
)
+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 -25
View File
@@ -118,19 +118,6 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = {
_ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {}
def _clear_scan_rerun_state(tenant_id: str, scan_id: str) -> None:
"""Remove rows derived from a previous execution of this scan."""
with rls_transaction(tenant_id):
Finding.all_objects.filter(scan_id=scan_id).delete()
ResourceScanSummary.objects.filter(scan_id=scan_id).delete()
ScanCategorySummary.objects.filter(scan_id=scan_id).delete()
ScanGroupSummary.objects.filter(scan_id=scan_id).delete()
ScanSummary.objects.filter(scan_id=scan_id).delete()
AttackSurfaceOverview.objects.filter(scan_id=scan_id).delete()
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete()
def aggregate_category_counts(
categories: list[str],
severity: str,
@@ -489,13 +476,9 @@ def _create_compliance_summaries(
)
)
# Idempotent re-run: clear this scan's prior summaries before re-inserting, so
# a recovered scan's summary always reflects its own (re-derived) requirement
# rows rather than keeping a stale row (bulk_create ignore_conflicts alone would
# keep the old one).
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
)
@@ -1039,7 +1022,6 @@ def perform_prowler_scan(
scan_instance.state = StateChoices.EXECUTING
scan_instance.started_at = datetime.now(tz=timezone.utc)
scan_instance.save(update_fields=["state", "started_at", "updated_at"])
_clear_scan_rerun_state(tenant_id, scan_id)
# Find the mutelist processor if it exists
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
@@ -1669,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
+2 -50
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
@@ -469,12 +462,6 @@ 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)
@@ -549,16 +536,7 @@ 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
@@ -583,10 +561,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)
@@ -641,30 +615,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
+1 -39
View File
@@ -1,12 +1,11 @@
from unittest.mock import call, patch
from uuid import uuid4
import pytest
from django.core.exceptions import ObjectDoesNotExist
from tasks.jobs.deletion import delete_provider, delete_tenant
from api.attack_paths import database as graph_database
from api.models import JiraIssueDispatch, Provider, Tenant, TenantComplianceSummary
from api.models import Provider, Tenant, TenantComplianceSummary
@pytest.mark.django_db
@@ -35,43 +34,6 @@ class TestDeleteProvider:
str(instance.id),
)
def test_delete_provider_removes_jira_dispatches(
self,
providers_fixture,
findings_fixture,
integrations_fixture,
):
"""Deleting a provider removes JiraIssueDispatch rows for its findings only."""
instance = providers_fixture[0]
tenant_id = str(instance.tenant_id)
finding = findings_fixture[0]
integration = integrations_fixture[0]
# Dispatch for one of the provider's findings: must be removed with it.
JiraIssueDispatch.objects.create(
tenant_id=tenant_id,
integration=integration,
finding_id=finding.id,
)
# Dispatch for an unrelated finding: must survive the provider deletion.
unrelated = JiraIssueDispatch.objects.create(
tenant_id=tenant_id,
integration=integration,
finding_id=uuid4(),
)
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
return_value="tenant-db",
),
patch("tasks.jobs.deletion.graph_database.drop_subgraph"),
):
delete_provider(tenant_id, instance.id)
assert not JiraIssueDispatch.objects.filter(finding_id=finding.id).exists()
assert JiraIssueDispatch.objects.filter(pk=unrelated.pk).exists()
def test_delete_provider_does_not_exist(self, tenants_fixture):
with (
patch(
@@ -1640,74 +1640,14 @@ class TestJiraIntegration:
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_skips_already_dispatched(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""A re-run skips findings already ticketed (no duplicate Jira issues)."""
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
mock_integration_model.objects.get.return_value = MagicMock()
# finding-1 was already dispatched in a prior run; finding-2 is new.
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [
"finding-1"
]
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
mock_jira_integration = MagicMock()
mock_jira_integration.send_finding.return_value = True
mock_initialize_integration.return_value = mock_jira_integration
finding2 = MagicMock()
finding2.id = "finding-2"
finding2.check_id = "check_002"
finding2.severity = "low"
finding2.status = "FAIL"
finding2.status_extended = ""
finding2.compliance = {}
finding2.resources.exists.return_value = False
finding2.resources.first.return_value = None
finding2.scan.provider.provider = "aws"
finding2.check_metadata = {
"checktitle": "C2",
"risk": "",
"remediation": {"recommendation": {}, "code": {}},
}
mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding2
result = send_findings_to_jira(
"tenant-123", "integration-456", "PROJ", "Task", ["finding-1", "finding-2"]
)
# finding-1 skipped (already sent); only finding-2 sent -> no duplicate.
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 1}
mock_jira_integration.send_finding.assert_called_once()
assert (
mock_jira_integration.send_finding.call_args.kwargs["check_id"]
== "check_002"
)
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_success(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""Test successful sending of findings to Jira using send_finding method"""
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -1799,7 +1739,7 @@ class TestJiraIntegration:
)
# Assertions
assert result == {"created_count": 2, "failed_count": 0, "skipped_count": 0}
assert result == {"created_count": 2, "failed_count": 0}
# Verify Jira integration was initialized
mock_initialize_integration.assert_called_once_with(integration)
@@ -1831,10 +1771,8 @@ class TestJiraIntegration:
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.logger")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_partial_failure(
self,
mock_jira_dispatch,
mock_logger,
mock_initialize_integration,
mock_integration_model,
@@ -1842,8 +1780,6 @@ class TestJiraIntegration:
mock_rls_transaction,
):
"""Test partial failure when sending findings to Jira"""
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -1897,35 +1833,23 @@ class TestJiraIntegration:
)
# Assertions
assert result == {"created_count": 2, "failed_count": 1, "skipped_count": 0}
assert result == {"created_count": 2, "failed_count": 1}
# Verify error was logged for the failed finding
mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira")
# The failed finding's reservation is released so a later run can retry it.
mock_jira_dispatch.objects.filter.assert_any_call(
tenant_id=tenant_id,
integration_id=integration_id,
finding_id="finding-2",
)
mock_jira_dispatch.objects.filter.return_value.delete.assert_called_once()
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_no_resources(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""Test sending findings to Jira when finding has no resources"""
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -1983,7 +1907,7 @@ class TestJiraIntegration:
)
# Assertions
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
assert result == {"created_count": 1, "failed_count": 0}
# Verify send_finding was called with empty resource fields
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
@@ -1996,18 +1920,14 @@ class TestJiraIntegration:
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_with_empty_check_metadata(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""Test sending findings to Jira when check_metadata is empty or missing fields"""
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -2050,7 +1970,7 @@ class TestJiraIntegration:
)
# Assertions
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
assert result == {"created_count": 1, "failed_count": 0}
# Verify send_finding was called with default/empty values
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
@@ -2063,94 +1983,3 @@ class TestJiraIntegration:
assert call_kwargs["remediation_code_cli"] == ""
assert call_kwargs["remediation_code_other"] == ""
assert call_kwargs["compliance"] == {}
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_reserves_before_sending(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""The dispatch row is reserved before the external Jira call (reserve-then-act)."""
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
mock_integration_model.objects.get.return_value = MagicMock()
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
order = []
mock_jira_dispatch.objects.get_or_create.side_effect = lambda **kw: (
order.append(("reserve", kw)) or (MagicMock(), True)
)
mock_jira_integration = MagicMock()
mock_jira_integration.send_finding.side_effect = lambda **kw: (
order.append(("send", kw)) or True
)
mock_initialize_integration.return_value = mock_jira_integration
finding = MagicMock()
finding.id = "finding-1"
finding.check_id = "check_001"
finding.severity = "low"
finding.status = "FAIL"
finding.status_extended = ""
finding.compliance = {}
finding.resources.exists.return_value = False
finding.resources.first.return_value = None
finding.scan.provider.provider = "aws"
finding.check_metadata = {
"checktitle": "C1",
"risk": "",
"remediation": {"recommendation": {}, "code": {}},
}
mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding
result = send_findings_to_jira(
"tenant-123", "integration-456", "PROJ", "Task", ["finding-1"]
)
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
# Reservation must precede the external send.
assert [entry[0] for entry in order] == ["reserve", "send"]
# A successful send keeps the reservation (no rollback delete).
mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called()
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_skips_when_already_reserved(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""A finding that races past the bulk pre-check but loses the reservation
(created=False) is skipped without a second issue, leaving the row intact."""
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
mock_integration_model.objects.get.return_value = MagicMock()
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
# Another concurrent run already created the dispatch row.
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), False)
mock_jira_integration = MagicMock()
mock_initialize_integration.return_value = mock_jira_integration
result = send_findings_to_jira(
"tenant-123", "integration-456", "PROJ", "Task", ["finding-1"]
)
assert result == {"created_count": 0, "failed_count": 0, "skipped_count": 1}
mock_jira_integration.send_finding.assert_not_called()
# The reservation belongs to the run that won the race; do not delete it.
mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called()
@@ -1,372 +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_celery_results.models import TaskResult
from api.models import Scan, StateChoices
from api.models import Task as APITask
from tasks.jobs.orphan_recovery import (
_decode_celery_field,
_reconcile_task_results,
_recovery_attempt_count,
_reenqueue_scan,
advisory_lock,
is_worker_alive,
)
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()
def test_jira_integration_task_is_reenqueued(self, tenants_fixture):
"""integration-jira is re-enqueued: its JiraIssueDispatch reservation makes a
re-run skip already-ticketed findings, so recovery cannot duplicate issues."""
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["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"] == kwargs
assert call["task_id"] != tr.task_id # fresh task id
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 TestScanRecovery:
"""Scans are recovered by re-running scan-perform on the EXISTING scan row,
so even a scheduled-scan orphan (whose own task would no-op on its guard) is
actually re-executed."""
def _scan_orphan(self, tenant, provider, name):
old_id = str(uuid4())
tr = TaskResult.objects.create(
task_id=old_id,
status=states.STARTED,
task_name=name,
worker="dead@gone",
task_kwargs=repr(
{"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
),
task_args=repr([]),
)
TaskResult.objects.filter(pk=tr.pk).update(
date_created=datetime.now(tz=timezone.utc) - timedelta(minutes=60)
)
APITask.objects.create(id=old_id, tenant_id=tenant.id, task_runner_task=tr)
scan = Scan.objects.create(
name="scan-orphan",
provider=provider,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.EXECUTING,
tenant_id=tenant.id,
task_id=old_id,
recovery_count=0,
)
return old_id, scan
@pytest.mark.parametrize("name", ["scan-perform", "scan-perform-scheduled"])
def test_scan_recovered_via_scan_perform(
self, tenants_fixture, providers_fixture, name
):
tenant, provider = tenants_fixture[0], providers_fixture[0]
old_id, scan = self._scan_orphan(tenant, provider, name)
with (
patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=False),
patch("tasks.jobs.orphan_recovery.revoke_task"),
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
patch("tasks.tasks.perform_scan_task") as mock_scan_task,
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert old_id in result["recovered"]
scan.refresh_from_db()
assert str(scan.task_id) != old_id # relinked to a fresh task
assert scan.recovery_count == 1
assert TaskResult.objects.get(task_id=old_id).status == states.REVOKED
# Recovered by re-running scan-perform on the existing scan row (so the
# scheduled guard cannot no-op it), regardless of the original task name.
mock_scan_task.apply_async.assert_called_once()
assert mock_scan_task.apply_async.call_args.kwargs["kwargs"]["scan_id"] == str(
scan.id
)
def test_reenqueue_skips_when_scan_already_repointed(
self, tenants_fixture, providers_fixture
):
# The scan already points at a newer task, so a stale orphan must not relink
# it or launch a second concurrent run against the same scan row.
tenant, provider = tenants_fixture[0], providers_fixture[0]
newer_id = str(uuid4())
tr = TaskResult.objects.create(
task_id=newer_id, status=states.STARTED, task_name="scan-perform"
)
APITask.objects.create(id=newer_id, tenant_id=tenant.id, task_runner_task=tr)
scan = Scan.objects.create(
name="scan-orphan",
provider=provider,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.EXECUTING,
tenant_id=tenant.id,
task_id=newer_id,
recovery_count=0,
)
with patch("tasks.tasks.perform_scan_task") as mock_scan_task:
recovered = _reenqueue_scan(str(uuid4()), scan)
assert recovered is False
mock_scan_task.apply_async.assert_not_called()
scan.refresh_from_db()
assert scan.recovery_count == 0
@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
@@ -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",
-184
View File
@@ -32,15 +32,12 @@ from tasks.utils import CustomEncoder
from api.db_router import MainRouter
from api.exceptions import ProviderConnectionError
from api.models import (
AttackSurfaceOverview,
Finding,
MuteRule,
Provider,
Resource,
ResourceScanSummary,
Scan,
ScanCategorySummary,
ScanGroupSummary,
ScanSummary,
StateChoices,
StatusChoices,
@@ -232,131 +229,6 @@ class TestPerformScan:
# Assert that failed_findings_count is 0 (finding is PASS and muted)
assert scan_resource.failed_findings_count == 0
def test_perform_prowler_scan_idempotent_on_rerun(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""Re-running a scan for the same scan_id must not duplicate findings."""
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
new_callable=dict,
),
patch("api.compliance.PROWLER_CHECKS", new_callable=dict) as mock_checks,
):
mock_checks["aws"] = {"check1": {"compliance1"}}
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
stale_resource = Resource.objects.create(
tenant_id=tenant.id,
provider=provider,
uid="stale_resource_uid",
name="stale",
region="stale-region",
service="stale-service",
type="stale-type",
)
ResourceScanSummary.objects.create(
tenant_id=tenant.id,
scan_id=scan.id,
resource_id=stale_resource.id,
service="stale-service",
region="stale-region",
resource_type="stale-type",
)
ScanCategorySummary.objects.create(
tenant_id=tenant.id,
scan=scan,
category="stale-category",
severity=Severity.medium,
total_findings=1,
)
ScanGroupSummary.objects.create(
tenant_id=tenant.id,
scan=scan,
resource_group="stale-group",
severity=Severity.medium,
total_findings=1,
)
ScanSummary.objects.create(
tenant_id=tenant.id,
scan=scan,
check_id="stale_check",
service="stale-service",
severity=Severity.medium,
region="stale-region",
total=1,
)
AttackSurfaceOverview.objects.create(
tenant_id=tenant.id,
scan=scan,
attack_surface_type=AttackSurfaceOverview.AttackSurfaceTypeChoices.SECRETS,
total_findings=1,
)
finding = MagicMock()
finding.uid = "dup_probe_finding"
finding.status = StatusChoices.PASS
finding.status_extended = "x"
finding.severity = Severity.medium
finding.check_id = "check1"
finding.get_metadata.return_value = {"key": "value"}
finding.resource_uid = "resource_uid"
finding.resource_name = "resource_name"
finding.region = "region"
finding.service_name = "service_name"
finding.resource_type = "resource_type"
finding.resource_tags = {}
finding.muted = False
finding.raw = {}
finding.resource_metadata = {}
finding.resource_details = {}
finding.partition = "partition"
finding.compliance = {}
mock_scan_instance = MagicMock()
mock_scan_instance.scan.return_value = [(100, [finding])]
mock_prowler_scan_class.return_value = mock_scan_instance
mock_provider_instance = MagicMock()
mock_provider_instance.get_regions.return_value = ["region"]
mock_initialize_prowler_provider.return_value = mock_provider_instance
# Run the same scan twice (simulating an orphan-recovery re-run).
perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"])
perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"])
# Neither findings nor resources are duplicated by the re-run: findings are
# scope-deleted before re-insert; resources are upserted by (tenant, provider, uid).
assert Finding.objects.filter(scan=scan).count() == 1
assert Resource.objects.filter(provider=provider).count() == 2
assert ResourceScanSummary.objects.filter(scan_id=scan.id).count() == 1
assert not ResourceScanSummary.objects.filter(
scan_id=scan.id, resource_id=stale_resource.id
).exists()
assert not ScanCategorySummary.objects.filter(scan=scan).exists()
assert not ScanGroupSummary.objects.filter(scan=scan).exists()
assert not ScanSummary.objects.filter(
scan=scan, check_id="stale_check"
).exists()
assert not AttackSurfaceOverview.objects.filter(scan=scan).exists()
@patch("tasks.jobs.scan.ProwlerScan")
@patch(
"tasks.jobs.scan.initialize_prowler_provider",
@@ -2008,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,
-43
View File
@@ -323,7 +323,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 +441,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 +596,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 +670,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"),
@@ -1117,7 +1113,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 +1145,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 +1241,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 +1273,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 +1366,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 +1394,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 +2706,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
+54 -4
View File
@@ -4410,8 +4410,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.27.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
version = "5.29.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29#a769e3761532d9332cb64078ef09ebf7ffb15292" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4484,9 +4484,13 @@ dependencies = [
{ name = "pygithub" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "scaleway" },
{ name = "schema" },
{ name = "shodan" },
{ name = "slack-sdk" },
{ name = "stackit-core" },
{ name = "stackit-iaas" },
{ name = "stackit-resourcemanager" },
{ name = "tabulate" },
{ name = "tzlocal" },
{ name = "uuid6" },
@@ -4494,7 +4498,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.31.0"
version = "1.30.2"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4590,7 +4594,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -5526,6 +5530,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
name = "stackit-core"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" },
]
[[package]]
name = "stackit-iaas"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "stackit-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" },
]
[[package]]
name = "stackit-resourcemanager"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "stackit-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
]
[[package]]
name = "statsd"
version = "4.0.1"
-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
@@ -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.
@@ -20,8 +20,7 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
_Commands_:
<CodeGroup>
```bash macOS/Linux
```bash
VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name)
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
@@ -29,15 +28,6 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
docker compose up -d
```
```powershell Windows PowerShell
$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
docker compose up -d
```
</CodeGroup>
<Callout icon="lock" iconType="regular" color="#e74c3c">
For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
</Callout>
@@ -128,8 +118,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.29.0"
PROWLER_API_VERSION="5.29.0"
PROWLER_UI_VERSION="5.28.0"
PROWLER_API_VERSION="5.28.0"
```
<Note>
@@ -47,7 +47,11 @@ 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 will be able to delete himself and not others, regardless of his permissions.**
> **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**.
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
-9
View File
@@ -2,15 +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)
---
## [5.29.1] (Prowler v5.29.1)
### 🐞 Fixed
+65
View File
@@ -85,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
@@ -801,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/"
@@ -904,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/"
@@ -1007,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/"
@@ -1241,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/"
@@ -1269,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/"
File diff suppressed because it is too large Load Diff
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"
]
}
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -48,7 +48,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.30.0"
prowler_version = "5.29.2"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+31 -41
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,
+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
@@ -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
@@ -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
+1 -1
View File
@@ -123,7 +123,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.13"
version = "5.30.0"
version = "5.29.2"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -94,6 +94,21 @@ class TestDispatchStartswith:
display_compliance_table(compliance_framework=framework_name, **_COMMON)
mock_fn.assert_called_once()
@pytest.mark.parametrize(
"framework_name",
[
"csa_ccm_4.0_aws",
"csa_ccm_4.0_azure",
"csa_ccm_4.0_gcp",
"csa_ccm_4.0_oraclecloud",
"csa_ccm_4.0_alibabacloud",
],
)
@patch(f"{MODULE}.get_csa_table")
def test_csa_dispatch(self, mock_fn, framework_name):
display_compliance_table(compliance_framework=framework_name, **_COMMON)
mock_fn.assert_called_once()
@pytest.mark.parametrize(
"framework_name",
["c5_aws", "c5_azure", "c5_gcp"],
@@ -12,7 +12,6 @@ Also validates that print_compliance_frameworks and print_compliance_requirement
work with universal ComplianceFramework objects (dict checks, None provider).
"""
import csv
import json
import os
from datetime import datetime, timezone
@@ -125,41 +124,6 @@ def _make_universal_framework(name="TestFW", version="1.0", with_table_config=Tr
)
def _make_framework_with_manual(name="MixedFW", version="1.0"):
"""Framework with one aws-covered requirement and one manual one.
The manual requirement has no aws checks, so for provider ``aws`` it is
emitted as a manual row/event used to assert manual requirements are
not duplicated when the writer is reused across streaming batches.
"""
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="Covered requirement",
attributes={"Section": "IAM"},
checks={"aws": ["check_a"]},
),
UniversalComplianceRequirement(
id="2.1",
description="Manual requirement",
attributes={"Section": "GOV"},
checks={"aws": []},
),
]
metadata = [AttributeMetadata(key="Section", type="str")]
outputs = OutputsConfig(table_config=TableConfig(group_by="Section"))
return ComplianceFramework(
framework=name,
name=f"{name} Framework",
provider="AWS",
version=version,
description="Test framework",
requirements=reqs,
attributes_metadata=metadata,
outputs=outputs,
)
# ── Tests ────────────────────────────────────────────────────────────
@@ -764,243 +728,3 @@ class TestIdempotency:
# FW1 writer instances unchanged
assert second_writers[0] is first_writers[0]
assert second_writers[1] is first_writers[1]
class TestStreamingBatches:
"""Streaming-aware behaviour: ``from_cli`` / ``is_last`` / ``_flush``.
Regression coverage for the API streaming path where the helper is
invoked once per finding batch: before the fix only the first batch
was written (batches 2..N silently dropped) and manual requirements
were re-emitted on every batch.
"""
def _run_batches(self, tmp_path, fw, key, batches):
"""Invoke the helper once per (findings, is_last) batch, sharing
``generated_outputs`` so writers are reused like the API does."""
generated = {"compliance": []}
for findings, is_last in batches:
process_universal_compliance_frameworks(
input_compliance_frameworks={key},
universal_frameworks={key: fw},
finding_outputs=findings,
output_directory=str(tmp_path),
output_filename="out",
provider="aws",
generated_outputs=generated,
from_cli=False,
is_last=is_last,
)
return generated
def test_defaults_preserve_cli_single_call(self, tmp_path):
"""Defaults (``from_cli=True``, ``is_last=True``): a single call
still finalizes a valid, closed OCSF JSON array (CLI unchanged)."""
fw = _make_universal_framework()
generated = {"compliance": []}
process_universal_compliance_frameworks(
input_compliance_frameworks={"test_fw_1.0"},
universal_frameworks={"test_fw_1.0": fw},
finding_outputs=[_make_finding("check_a")],
output_directory=str(tmp_path),
output_filename="out",
provider="aws",
generated_outputs=generated,
)
ocsf_path = tmp_path / "compliance" / "out_test_fw_1.0.ocsf.json"
data = json.loads(ocsf_path.read_text())
assert isinstance(data, list) and len(data) >= 1
def test_multibatch_csv_keeps_every_batch(self, tmp_path):
"""Findings from batches 2..N must not be dropped (the bug)."""
fw = _make_universal_framework()
f1 = _make_finding("check_a", status="PASS")
f2 = _make_finding("check_a", status="FAIL")
generated = self._run_batches(
tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)]
)
content = (tmp_path / "compliance" / "out_fw_1.0.csv").read_text()
assert "check_a is PASS" in content # batch 1
assert "check_a is FAIL" in content # batch 2 — regression
# writer reused, not recreated: still just 1 CSV + 1 OCSF
assert len(generated["compliance"]) == 2
def test_multibatch_ocsf_valid_array_with_every_batch(self, tmp_path):
"""OCSF is a valid (closed) JSON array holding every batch's
events only after the ``is_last=True`` call."""
fw = _make_universal_framework()
f1 = _make_finding("check_a", status="PASS")
f2 = _make_finding("check_a", status="FAIL")
self._run_batches(tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)])
data = json.loads(
(tmp_path / "compliance" / "out_fw_1.0.ocsf.json").read_text()
)
assert isinstance(data, list)
assert len(data) >= 2 # one event per batch finding
def test_manual_requirement_not_duplicated_across_batches(self, tmp_path):
"""Manual requirement is emitted once (first batch, via __init__),
never re-emitted when the writer is reused (``include_manual=False``)."""
fw = _make_framework_with_manual()
f1 = _make_finding("check_a", status="PASS")
f2 = _make_finding("check_a", status="FAIL")
self._run_batches(tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)])
rows = list(
csv.DictReader(
(tmp_path / "compliance" / "out_fw_1.0.csv").read_text().splitlines(),
delimiter=";",
)
)
manual_rows = [r for r in rows if r["STATUS"] == "MANUAL"]
assert len(manual_rows) == 1
assert manual_rows[0]["REQUIREMENTS_ID"] == "2.1"
ocsf = json.loads(
(tmp_path / "compliance" / "out_fw_1.0.ocsf.json").read_text()
)
manual_events = [
e
for e in ocsf
if (e.get("compliance") or {}).get("requirements") == ["2.1"]
]
assert len(manual_events) == 1
def test_writer_reused_not_recreated_across_batches(self, tmp_path):
"""Three batches still yield exactly one CSV + one OCSF writer,
and the same instances are reused throughout."""
fw = _make_universal_framework()
generated = self._run_batches(
tmp_path,
fw,
"fw_1.0",
[
([_make_finding("check_a")], False),
([_make_finding("check_a")], False),
([_make_finding("check_a")], True),
],
)
assert len(generated["compliance"]) == 2
assert isinstance(generated["compliance"][0], UniversalComplianceOutput)
assert isinstance(generated["compliance"][1], OCSFComplianceOutput)
def test_label_without_version_still_outputs(self, tmp_path):
"""Empty framework version → label is the framework name only;
the helper still produces both artifacts without error."""
fw = _make_universal_framework(version="")
generated = {"compliance": []}
processed = process_universal_compliance_frameworks(
input_compliance_frameworks={"fw"},
universal_frameworks={"fw": fw},
finding_outputs=[_make_finding("check_a")],
output_directory=str(tmp_path),
output_filename="out",
provider="aws",
generated_outputs=generated,
from_cli=False,
is_last=True,
)
assert processed == {"fw"}
assert len(generated["compliance"]) == 2
assert (tmp_path / "compliance" / "out_fw.csv").exists()
assert (tmp_path / "compliance" / "out_fw.ocsf.json").exists()
def _csa_like_framework() -> ComplianceFramework:
"""Build a CSA CCM-style universal framework with checks across providers."""
requirement = UniversalComplianceRequirement(
id="A&A-01",
description="Audit and Assurance",
attributes={"Section": "Audit"},
checks={
"aws": ["aws_check"],
"azure": ["azure_check"],
"gcp": ["gcp_check"],
},
)
return ComplianceFramework(
framework="CSA_CCM",
name="CSA Cloud Controls Matrix",
version="4.0",
description="Multi-provider framework",
requirements=[requirement],
attributes_metadata=[AttributeMetadata(key="Section", type="str")],
outputs=OutputsConfig(table_config=TableConfig(group_by="Section")),
)
class TestMultiProviderUniversalFramework:
"""A top-level CSA-CCM-style framework produces a CSV+OCSF pair scoped
to the provider it is invoked with."""
@pytest.mark.parametrize(
"provider,check_id",
[
("aws", "aws_check"),
("azure", "azure_check"),
("gcp", "gcp_check"),
],
)
def test_per_provider_outputs_isolated(self, tmp_path, provider, check_id):
framework = _csa_like_framework()
generated = {"compliance": []}
process_universal_compliance_frameworks(
input_compliance_frameworks={"csa_ccm_4.0"},
universal_frameworks={"csa_ccm_4.0": framework},
finding_outputs=[_make_finding(check_id, provider=provider)],
output_directory=str(tmp_path),
output_filename="prowler_output",
provider=provider,
generated_outputs=generated,
)
ocsf_path = tmp_path / "compliance" / "prowler_output_csa_ccm_4.0.ocsf.json"
events = json.loads(ocsf_path.read_text())
assert isinstance(events, list)
non_manual = [event for event in events if event.get("status_code") != "MANUAL"]
assert len(non_manual) == 1
assert non_manual[0]["compliance"]["checks"][0]["uid"] == check_id
class TestMitreStyleOCSFOutput:
"""MITRE attrs wrapped as `{"_raw_attributes": [...]}` must not leak
the marker key through the OCSF pipeline."""
def test_mitre_raw_attributes_pass_through_pipeline(self, tmp_path):
mitre_requirement = UniversalComplianceRequirement(
id="T1078",
description="Valid Accounts",
attributes={
"_raw_attributes": [{"AWSService": "IAM", "Category": "Initial Access"}]
},
checks={"aws": ["check_a"]},
)
framework = ComplianceFramework(
framework="MITRE",
name="MITRE ATT&CK",
version="14",
description="Mitre",
requirements=[mitre_requirement],
outputs=OutputsConfig(table_config=TableConfig(group_by="AWSService")),
)
generated = {"compliance": []}
process_universal_compliance_frameworks(
input_compliance_frameworks={"mitre_attack_aws"},
universal_frameworks={"mitre_attack_aws": framework},
finding_outputs=[_make_finding("check_a", "PASS")],
output_directory=str(tmp_path),
output_filename="out",
provider="aws",
generated_outputs=generated,
)
ocsf_path = tmp_path / "compliance" / "out_mitre_attack_aws.ocsf.json"
events = json.loads(ocsf_path.read_text())
assert isinstance(events, list) and len(events) >= 1
for event in events:
requirement_attrs = (event.get("unmapped") or {}).get(
"requirement_attributes", {}
)
assert "_raw_attributes" not in requirement_attrs
assert "raw_attributes" not in requirement_attrs
@@ -202,26 +202,6 @@ class TestOCSFComplianceOutput:
assert cf.status_code == "MANUAL"
assert cf.finding_info.uid == "manual-MANUAL-1"
def test_include_manual_false_skips_manual(self):
"""``_transform(..., include_manual=False)`` emits check events but
NOT manual requirement events. The streaming caller passes ``False``
for batches 2..N so manual events are not duplicated."""
covered = _simple_requirement("REQ-1", ["check_a"])
manual = _simple_requirement("MANUAL-1", checks=[])
fw = _make_framework([covered, manual])
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
# __init__ transforms with include_manual=True (default) → manual present
assert any(cf.status_code == "MANUAL" for cf in output.data)
# A subsequent batch re-transforms with include_manual=False
output._data.clear()
output._transform(findings, fw, "TestFW-1.0", include_manual=False)
assert len(output.data) == 1 # only the check event, no manual
assert all(cf.status_code != "MANUAL" for cf in output.data)
def test_multi_provider_checks_dict(self):
req = UniversalComplianceRequirement(
id="REQ-1",
@@ -651,103 +631,3 @@ class TestNoTopLevelOCSFImport:
import prowler.lib.outputs.compliance.universal.ocsf_compliance as mod
assert "OCSF" not in dir(mod)
def _mitre_requirement(req_id="T1078", entries=None):
"""Build a MITRE-style requirement with `_raw_attributes` wrapping."""
return UniversalComplianceRequirement(
id=req_id,
description="Valid Accounts",
attributes={
"_raw_attributes": entries
or [{"AWSService": "IAM", "Category": "Initial Access"}]
},
checks={"aws": ["check_a"]},
)
class TestMitreRawAttributes:
"""MITRE attrs wrapped as `{"_raw_attributes": [...]}` must not leak
the marker key into the OCSF payload."""
def test_raw_attributes_key_not_in_unmapped(self):
framework = _make_framework([_mitre_requirement()])
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(
findings=findings, framework=framework, provider="aws"
)
requirement_attrs = (output.data[0].unmapped or {}).get(
"requirement_attributes", {}
)
assert "_raw_attributes" not in requirement_attrs
assert "raw_attributes" not in requirement_attrs
def test_finding_serializes_with_raw_attributes(self):
framework = _make_framework(
[
_mitre_requirement(
entries=[
{"AWSService": "IAM", "Category": "Initial Access"},
{"AWSService": "STS", "Category": "Privilege Escalation"},
]
)
]
)
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(
findings=findings, framework=framework, provider="aws"
)
compliance_finding = output.data[0]
if hasattr(compliance_finding, "model_dump_json"):
payload = json.loads(compliance_finding.model_dump_json(exclude_none=True))
else:
payload = json.loads(compliance_finding.json(exclude_none=True))
assert payload["compliance"]["requirements"] == ["T1078"]
class TestProviderFiltering:
"""OCSF writer scopes findings against `requirement.checks[provider]`."""
def test_check_for_other_provider_not_emitted(self):
azure_only_requirement = UniversalComplianceRequirement(
id="REQ-1",
description="Azure-only requirement",
attributes={},
checks={"azure": ["check_a"]},
)
framework = _make_framework([azure_only_requirement])
findings = [_make_finding("check_a", "PASS", provider="aws")]
output = OCSFComplianceOutput(
findings=findings, framework=framework, provider="aws"
)
assert all(
compliance_finding.status_code == "MANUAL"
for compliance_finding in output.data
)
def test_no_provider_aggregates_all_checks(self):
multi_provider_requirement = UniversalComplianceRequirement(
id="REQ-1",
description="Multi-provider requirement",
attributes={},
checks={"aws": ["check_a"], "azure": ["check_b"]},
)
framework = _make_framework([multi_provider_requirement])
findings = [
_make_finding("check_a", "PASS", provider="aws"),
_make_finding("check_b", "FAIL", provider="azure"),
]
output = OCSFComplianceOutput(
findings=findings, framework=framework, provider=None
)
statuses = sorted(
compliance_finding.status_code for compliance_finding in output.data
)
assert statuses == ["FAIL", "PASS"]
@@ -122,43 +122,6 @@ class TestManualRequirements:
assert manual_rows[0].dict()["Requirements_Id"] == "manual-1"
assert manual_rows[0].dict()["ResourceId"] == "manual_check"
def test_include_manual_false_skips_manual_rows(self, tmp_path):
"""``_transform(..., include_manual=False)`` emits finding rows but
NOT manual requirements. The streaming caller passes ``False`` for
batches 2..N so manual rows are not duplicated across batches."""
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "IAM"},
checks={"aws": ["check_a"]},
),
UniversalComplianceRequirement(
id="manual-1",
description="manual check",
attributes={"Section": "Governance"},
checks={},
),
]
metadata = [AttributeMetadata(key="Section", type="str")]
fw = _make_framework(reqs, metadata, TableConfig(group_by="Section"))
findings = [_make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]})]
output = UniversalComplianceOutput(
findings=findings,
framework=fw,
file_path=str(tmp_path / "t.csv"),
)
# __init__ transforms with include_manual=True (default) → manual present
assert any(r.dict()["Status"] == "MANUAL" for r in output.data)
# A subsequent batch re-transforms with include_manual=False
output._data.clear()
output._transform(findings, fw, "TestFW-1.0", include_manual=False)
assert len(output.data) == 1 # only the finding row, no manual
assert all(r.dict()["Status"] != "MANUAL" for r in output.data)
class TestMITREExtraColumns:
def test_mitre_columns_present(self, tmp_path):
@@ -1,229 +0,0 @@
from unittest import mock
from prowler.providers.aws.services.sagemaker.sagemaker_service import (
MonitoringSchedule,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
test_monitoring_schedule = "test-monitoring-schedule"
monitoring_schedule_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/{test_monitoring_schedule}"
aggregate_name = "SageMaker Monitoring Schedules"
unknown_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/unknown"
class Test_sagemaker_models_monitor_enabled:
def test_no_models_monitoring_schedules_exist(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_monitoring_schedules = [
MonitoringSchedule(
name=aggregate_name,
region=AWS_REGION_EU_WEST_1,
arn=unknown_arn,
has_schedules=False,
is_scheduled=False,
)
]
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import (
sagemaker_models_monitor_enabled,
)
check = sagemaker_models_monitor_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].region == AWS_REGION_EU_WEST_1
assert (
result[0].status_extended
== f"No SageMaker monitoring schedules found in region {AWS_REGION_EU_WEST_1}."
)
assert result[0].resource_id == aggregate_name
assert result[0].resource_arn == unknown_arn
def test_region_with_schedules_but_none_scheduled(self):
# A region that has monitoring schedules but none in Scheduled state
# must FAIL once.
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_monitoring_schedules = [
MonitoringSchedule(
name=aggregate_name,
region=AWS_REGION_EU_WEST_1,
arn=unknown_arn,
has_schedules=True,
is_scheduled=False,
)
]
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import (
sagemaker_models_monitor_enabled,
)
check = sagemaker_models_monitor_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].region == AWS_REGION_EU_WEST_1
assert (
result[0].status_extended
== f"No active SageMaker monitoring schedule in region {AWS_REGION_EU_WEST_1}; existing schedules are not in Scheduled status."
)
def test_region_with_one_scheduled_passes(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_monitoring_schedules = [
MonitoringSchedule(
name=test_monitoring_schedule,
region=AWS_REGION_EU_WEST_1,
arn=monitoring_schedule_arn,
has_schedules=True,
is_scheduled=True,
)
]
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import (
sagemaker_models_monitor_enabled,
)
check = sagemaker_models_monitor_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].region == AWS_REGION_EU_WEST_1
assert (
result[0].status_extended
== f"SageMaker monitoring schedule {test_monitoring_schedule} is enabled in region {AWS_REGION_EU_WEST_1}."
)
assert result[0].resource_id == test_monitoring_schedule
assert result[0].resource_arn == monitoring_schedule_arn
def test_scheduled_not_masked_across_regions(self):
# Regression: a region without an active monitor must not mask a
# Scheduled monitor in another region; one finding per region.
scheduled_name = "scheduled-monitor"
scheduled_arn = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/{scheduled_name}"
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_monitoring_schedules = [
MonitoringSchedule(
name=aggregate_name,
region=AWS_REGION_EU_WEST_1,
arn=unknown_arn,
has_schedules=False,
is_scheduled=False,
),
MonitoringSchedule(
name=scheduled_name,
region=AWS_REGION_US_EAST_1,
arn=scheduled_arn,
has_schedules=True,
is_scheduled=True,
),
]
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import (
sagemaker_models_monitor_enabled,
)
check = sagemaker_models_monitor_enabled()
result = check.execute()
assert len(result) == 2
results_by_region = {r.region: r for r in result}
assert results_by_region[AWS_REGION_EU_WEST_1].status == "FAIL"
assert (
results_by_region[AWS_REGION_EU_WEST_1].status_extended
== f"No SageMaker monitoring schedules found in region {AWS_REGION_EU_WEST_1}."
)
assert results_by_region[AWS_REGION_US_EAST_1].status == "PASS"
assert (
results_by_region[AWS_REGION_US_EAST_1].status_extended
== f"SageMaker monitoring schedule {scheduled_name} is enabled in region {AWS_REGION_US_EAST_1}."
)
def test_empty_schedules_list(self):
# Regression: an empty list must not raise and must yield no findings.
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_monitoring_schedules = []
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import (
sagemaker_models_monitor_enabled,
)
check = sagemaker_models_monitor_enabled()
result = check.execute()
assert result == []
+12 -3
View File
@@ -2,11 +2,20 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.30.0] (Prowler UNRELEASED)
## [1.29.2] (Prowler v5.29.2)
### 🚀 Added
### 🔄 Changed
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
### 🐞 Fixed
- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447)
### 🔐 Security
- Vitest toolchain upgraded `4.0.18``4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
---
-21
View File
@@ -394,27 +394,6 @@ export const getComplianceCsv = async (scanId: string, complianceId: string) =>
"compliance report",
);
/**
* Get the OCSF JSON export for a universal compliance framework.
*
* Only universal frameworks that declare an ``outputs`` block (today: DORA,
* CSA CCM 4.0) produce a per-framework OCSF artifact. For any other framework
* the backend returns 404; callers should gate this download via
* ``isOcsfSupported(framework)``.
*
* NOTE: this is a dedicated path (``compliance/{id}/ocsf``), not a query
* param. The API's JSON:API ``QueryParameterValidationFilter`` rejects any
* non-JSON:API query param with 400, so ``?type=`` / ``?format=`` is not an
* option the format must be encoded in the route.
*/
export const getComplianceOcsf = async (scanId: string, complianceId: string) =>
_fetchScanBinary(
scanId,
`compliance/${complianceId}/ocsf`,
`scan-${scanId}-compliance-${complianceId}.ocsf.json`,
"compliance OCSF report",
);
/**
* Get a compliance PDF report for any supported framework.
*
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
);
},
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -220,4 +220,45 @@ describe("AccountsSelector", () => {
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
});
it("shows the provider icon next to the name in the trigger for a single selection", async () => {
render(
<AccountsSelector
providers={providers}
onBatchChange={vi.fn()}
selectedValues={["provider-1"]}
/>,
);
const trigger = screen.getByTestId("trigger");
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
expect(within(trigger).getByText("Production AWS")).toBeInTheDocument();
});
it("renders one icon per selected account without deduping by provider type", async () => {
const secondAws = {
...providers[0],
id: "provider-2",
attributes: {
...providers[0].attributes,
uid: "999999999999",
alias: "Staging AWS",
},
};
render(
<AccountsSelector
providers={[providers[0], secondAws]}
onBatchChange={vi.fn()}
selectedValues={["provider-1", "provider-2"]}
/>,
);
const trigger = screen.getByTestId("trigger");
// Two AWS accounts -> two AWS icons in the trigger (no dedupe).
expect(await within(trigger).findAllByText("AWS")).toHaveLength(2);
expect(
within(trigger).getByText("2 Providers selected"),
).toBeInTheDocument();
});
});
@@ -1,26 +1,12 @@
"use client";
import { useSearchParams } from "next/navigation";
import { ReactNode, useState } from "react";
import { useState } from "react";
import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
ProviderTypeIcon,
ProviderTypeIconStack,
} from "@/components/icons/providers-badge/provider-type-icon";
import { Badge } from "@/components/shadcn";
import {
MultiSelect,
@@ -45,25 +31,6 @@ const ACCOUNT_SELECTOR_FILTER = {
type AccountSelectorFilter =
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
azure: <AzureProviderBadge width={18} height={18} />,
gcp: <GCPProviderBadge width={18} height={18} />,
kubernetes: <KS8ProviderBadge width={18} height={18} />,
m365: <M365ProviderBadge width={18} height={18} />,
github: <GitHubProviderBadge width={18} height={18} />,
googleworkspace: <GoogleWorkspaceProviderBadge width={18} height={18} />,
iac: <IacProviderBadge width={18} height={18} />,
image: <ImageProviderBadge width={18} height={18} />,
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
openstack: <OpenStackProviderBadge width={18} height={18} />,
vercel: <VercelProviderBadge width={18} height={18} />,
okta: <OktaProviderBadge width={18} height={18} />,
};
/** Common props shared by both batch and instant modes. */
interface AccountsSelectorBaseProps {
providers: ProviderProps[];
@@ -158,10 +125,36 @@ export function AccountsSelector({
if (selectedIds.length === 1) {
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
return <span className="truncate">{name}</span>;
return (
<span className="flex min-w-0 items-center gap-2">
{p && (
<span aria-hidden="true">
<ProviderTypeIcon type={p.attributes.provider} />
</span>
)}
<span className="truncate">{name}</span>
</span>
);
}
// One icon per selected account (no dedupe): two accounts of the same
// provider show two icons, disambiguated by the UID tooltip on hover.
const items = selectedIds
.map((selectedId) =>
providers.find((pr) => getProviderValue(pr) === selectedId),
)
.filter((p): p is ProviderProps => Boolean(p))
.map((p) => ({
key: p.id,
type: p.attributes.provider as ProviderType,
tooltip: p.attributes.uid,
}));
return (
<span className="truncate">{selectedIds.length} Providers selected</span>
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIconStack items={items} />
<span className="truncate">
{selectedIds.length} Providers selected
</span>
</span>
);
};
@@ -208,7 +201,6 @@ export function AccountsSelector({
const isDisabled = disabledValuesSet.has(value);
const displayName = p.attributes.alias || p.attributes.uid;
const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType];
const searchKeywords = [
displayName,
p.attributes.alias,
@@ -228,7 +220,9 @@ export function AccountsSelector({
if (closeOnSelect) setSelectorOpen(false);
}}
>
<span aria-hidden="true">{icon}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{displayName}</span>
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ProviderTypeSelector } from "./provider-type-selector";
@@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
<div>{children}</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -145,4 +145,26 @@ describe("ProviderTypeSelector", () => {
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
it("shows one icon per selected type and a count in the trigger", async () => {
const azure = {
...providers[0],
id: "provider-2",
attributes: { ...providers[0].attributes, provider: "azure" as const },
};
render(
<ProviderTypeSelector
providers={[providers[0], azure]}
onBatchChange={vi.fn()}
selectedValues={["aws", "azure"]}
/>,
);
const trigger = screen.getByTestId("trigger");
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
expect(
within(trigger).getByText("2 Provider Types selected"),
).toBeInTheDocument();
});
});
@@ -1,8 +1,12 @@
"use client";
import { useSearchParams } from "next/navigation";
import { type ComponentType, lazy, Suspense } from "react";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
ProviderTypeIconStack,
} from "@/components/icons/providers-badge/provider-type-icon";
import {
MultiSelect,
MultiSelectContent,
@@ -14,163 +18,6 @@ import {
import { useUrlFilters } from "@/hooks/use-url-filters";
import { type ProviderProps, ProviderType } from "@/types/providers";
const AWSProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AWSProviderBadge,
})),
);
const AzureProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AzureProviderBadge,
})),
);
const GCPProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GCPProviderBadge,
})),
);
const KS8ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.KS8ProviderBadge,
})),
);
const M365ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.M365ProviderBadge,
})),
);
const GitHubProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GitHubProviderBadge,
})),
);
const IacProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.IacProviderBadge,
})),
);
const ImageProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.ImageProviderBadge,
})),
);
const OracleCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OracleCloudProviderBadge,
})),
);
const MongoDBAtlasProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.MongoDBAtlasProviderBadge,
})),
);
const AlibabaCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AlibabaCloudProviderBadge,
})),
);
const CloudflareProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.CloudflareProviderBadge,
})),
);
const OpenStackProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OpenStackProviderBadge,
})),
);
const GoogleWorkspaceProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GoogleWorkspaceProviderBadge,
})),
);
const VercelProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.VercelProviderBadge,
})),
);
const OktaProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OktaProviderBadge,
})),
);
type IconProps = { width: number; height: number };
const IconPlaceholder = ({ width, height }: IconProps) => (
<div style={{ width, height }} />
);
const PROVIDER_DATA: Record<
ProviderType,
{ label: string; icon: ComponentType<IconProps> }
> = {
aws: {
label: "Amazon Web Services",
icon: AWSProviderBadge,
},
azure: {
label: "Microsoft Azure",
icon: AzureProviderBadge,
},
gcp: {
label: "Google Cloud Platform",
icon: GCPProviderBadge,
},
kubernetes: {
label: "Kubernetes",
icon: KS8ProviderBadge,
},
m365: {
label: "Microsoft 365",
icon: M365ProviderBadge,
},
github: {
label: "GitHub",
icon: GitHubProviderBadge,
},
googleworkspace: {
label: "Google Workspace",
icon: GoogleWorkspaceProviderBadge,
},
iac: {
label: "Infrastructure as Code",
icon: IacProviderBadge,
},
image: {
label: "Container Registry",
icon: ImageProviderBadge,
},
oraclecloud: {
label: "Oracle Cloud Infrastructure",
icon: OracleCloudProviderBadge,
},
mongodbatlas: {
label: "MongoDB Atlas",
icon: MongoDBAtlasProviderBadge,
},
alibabacloud: {
label: "Alibaba Cloud",
icon: AlibabaCloudProviderBadge,
},
cloudflare: {
label: "Cloudflare",
icon: CloudflareProviderBadge,
},
openstack: {
label: "OpenStack",
icon: OpenStackProviderBadge,
},
vercel: {
label: "Vercel",
icon: VercelProviderBadge,
},
okta: {
label: "Okta",
icon: OktaProviderBadge,
},
};
/** Common props shared by both batch and instant modes. */
interface ProviderTypeSelectorBaseProps {
providers: ProviderProps[];
@@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({
.map((p) => p.attributes.provider),
),
)
.filter((type): type is ProviderType => type in PROVIDER_DATA)
.filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
.sort((a, b) =>
PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label),
);
const renderIcon = (providerType: ProviderType) => {
const IconComponent = PROVIDER_DATA[providerType].icon;
return (
<Suspense fallback={<IconPlaceholder width={24} height={24} />}>
<IconComponent width={24} height={24} />
</Suspense>
);
};
const selectedLabel = () => {
if (selectedTypes.length === 0) return null;
if (selectedTypes.length === 1) {
const providerType = selectedTypes[0] as ProviderType;
return (
<span className="flex min-w-0 items-center gap-2">
{renderIcon(providerType)}
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</span>
<span className="truncate">
{PROVIDER_TYPE_DATA[providerType].label}
</span>
</span>
);
}
return (
<span className="min-w-0 truncate">
{selectedTypes.length} Provider Types selected
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIconStack
items={(selectedTypes as ProviderType[]).map((type) => ({
key: type,
type,
tooltip: PROVIDER_TYPE_DATA[type].label,
}))}
/>
<span className="min-w-0 truncate">
{selectedTypes.length} Provider Types selected
</span>
</span>
);
};
@@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({
<MultiSelectItem
key={providerType}
value={providerType}
badgeLabel={PROVIDER_DATA[providerType].label}
keywords={[providerType, PROVIDER_DATA[providerType].label]}
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
keywords={[
providerType,
PROVIDER_TYPE_DATA[providerType].label,
]}
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
>
<span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} size={24} />
</span>
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
</MultiSelectItem>
))}
</>
+3
View File
@@ -109,6 +109,9 @@ const SSRDataTable = async ({
roles,
canBeExpelled,
currentTenantId: canBeExpelled ? currentTenantId : undefined,
// Users may only delete their own account; gate the delete action so the
// UI matches the backend rule and never offers an action that would fail.
isCurrentUser: user.id === currentUserId,
};
});
@@ -1,49 +0,0 @@
import { Requirement } from "@/types/compliance";
import {
ComplianceBadge,
ComplianceBadgeContainer,
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
} from "./shared-components";
interface DORADetailsProps {
requirement: Requirement;
}
export const DORACustomDetails = ({ requirement }: DORADetailsProps) => {
return (
<ComplianceDetailContainer>
{requirement.description && (
<ComplianceDetailSection title="Description">
<ComplianceDetailText>{requirement.description}</ComplianceDetailText>
</ComplianceDetailSection>
)}
<ComplianceBadgeContainer>
{requirement.pillar && (
<ComplianceBadge
label="Pillar"
value={requirement.pillar as string}
color="blue"
/>
)}
{requirement.article && (
<ComplianceBadge
label="Article"
value={requirement.article as string}
color="indigo"
/>
)}
{requirement.article_title && (
<ComplianceBadge
label="Article Title"
value={requirement.article_title as string}
color="gray"
/>
)}
</ComplianceBadgeContainer>
</ComplianceDetailContainer>
);
};
@@ -6,19 +6,15 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
downloadComplianceCsvMock,
downloadComplianceOcsfMock,
downloadCompliancePdfMock,
} = vi.hoisted(() => ({
downloadComplianceCsvMock: vi.fn(),
downloadComplianceOcsfMock: vi.fn(),
downloadCompliancePdfMock: vi.fn(),
}));
const { downloadComplianceCsvMock, downloadCompliancePdfMock } = vi.hoisted(
() => ({
downloadComplianceCsvMock: vi.fn(),
downloadCompliancePdfMock: vi.fn(),
}),
);
vi.mock("@/lib/helper", () => ({
downloadComplianceCsv: downloadComplianceCsvMock,
downloadComplianceOcsf: downloadComplianceOcsfMock,
downloadCompliancePdf: downloadCompliancePdfMock,
}));
@@ -135,51 +131,4 @@ describe("ComplianceDownloadContainer", () => {
{},
);
});
it("should hide the OCSF action for frameworks without OCSF support", async () => {
const user = userEvent.setup();
render(
<ComplianceDownloadContainer
compact
presentation="dropdown"
scanId="scan-1"
complianceId="compliance-1"
/>,
);
await user.click(
screen.getByRole("button", { name: "Open compliance export actions" }),
);
expect(screen.queryByText("Download OCSF report")).not.toBeInTheDocument();
});
it("should surface and trigger the OCSF download for universal frameworks", async () => {
const user = userEvent.setup();
render(
<ComplianceDownloadContainer
compact
presentation="dropdown"
scanId="scan-1"
complianceId="dora"
/>,
);
await user.click(
screen.getByRole("button", { name: "Open compliance export actions" }),
);
expect(screen.getByText("Download OCSF report")).toBeInTheDocument();
await user.click(
screen.getByRole("menuitem", { name: /Download OCSF report/i }),
);
expect(downloadComplianceOcsfMock).toHaveBeenCalledWith(
"scan-1",
"dora",
{},
);
});
});
@@ -1,6 +1,6 @@
"use client";
import { DownloadIcon, FileJsonIcon, FileTextIcon } from "lucide-react";
import { DownloadIcon, FileTextIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
@@ -14,15 +14,8 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { toast } from "@/components/ui";
import {
type ComplianceReportType,
isOcsfSupported,
} from "@/lib/compliance/compliance-report-types";
import {
downloadComplianceCsv,
downloadComplianceOcsf,
downloadCompliancePdf,
} from "@/lib/helper";
import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types";
import { downloadComplianceCsv, downloadCompliancePdf } from "@/lib/helper";
import { cn } from "@/lib/utils";
interface ComplianceDownloadContainerProps {
@@ -47,14 +40,9 @@ export const ComplianceDownloadContainer = ({
presentation = "buttons",
}: ComplianceDownloadContainerProps) => {
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
const [isDownloadingOcsf, setIsDownloadingOcsf] = useState(false);
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
const isIconWidth = buttonWidth === "icon";
const isDropdown = presentation === "dropdown";
// Only universal frameworks declaring an ``outputs`` block expose a
// per-framework OCSF artifact (today: DORA, CSA CCM 4.0). Hide the
// action everywhere else so the user never hits a guaranteed 404.
const ocsfAvailable = isOcsfSupported(complianceId);
const handleDownloadCsv = async () => {
if (isDownloadingCsv) return;
@@ -66,16 +54,6 @@ export const ComplianceDownloadContainer = ({
}
};
const handleDownloadOcsf = async () => {
if (!ocsfAvailable || isDownloadingOcsf) return;
setIsDownloadingOcsf(true);
try {
await downloadComplianceOcsf(scanId, complianceId, toast);
} finally {
setIsDownloadingOcsf(false);
}
};
const handleDownloadPdf = async () => {
if (!reportType || isDownloadingPdf) return;
setIsDownloadingPdf(true);
@@ -127,18 +105,6 @@ export const ComplianceDownloadContainer = ({
onSelect={handleDownloadCsv}
disabled={disabled || isDownloadingCsv}
/>
{ocsfAvailable && (
<ActionDropdownItem
icon={
<FileJsonIcon
className={isDownloadingOcsf ? "animate-download-icon" : ""}
/>
}
label="Download OCSF report"
onSelect={handleDownloadOcsf}
disabled={disabled || isDownloadingOcsf}
/>
)}
{reportType && (
<ActionDropdownItem
icon={
@@ -186,29 +152,6 @@ export const ComplianceDownloadContainer = ({
<TooltipContent>Download CSV report</TooltipContent>
)}
</Tooltip>
{ocsfAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className={buttonClassName}
onClick={handleDownloadOcsf}
disabled={disabled || isDownloadingOcsf}
aria-label="Download compliance OCSF report"
>
<FileJsonIcon
size={14}
className={isDownloadingOcsf ? "animate-download-icon" : ""}
/>
<span className={labelClassName}>OCSF</span>
</Button>
</TooltipTrigger>
{showTooltip && (
<TooltipContent>Download OCSF report</TooltipContent>
)}
</Tooltip>
)}
{reportType && (
<Tooltip>
<TooltipTrigger asChild>
@@ -0,0 +1,45 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderType } from "@/types/providers";
import { ProviderIconCell } from "./provider-icon-cell";
// Render the lazy provider badges as plain text so we can assert on them. The
// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose.
vi.mock("@/components/icons/providers-badge", () => ({
AWSProviderBadge: () => <span>AWS</span>,
AzureProviderBadge: () => <span>Azure</span>,
GCPProviderBadge: () => <span>GCP</span>,
KS8ProviderBadge: () => <span>Kubernetes</span>,
M365ProviderBadge: () => <span>M365</span>,
GitHubProviderBadge: () => <span>GitHub</span>,
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
IacProviderBadge: () => <span>IaC</span>,
ImageProviderBadge: () => <span>Image</span>,
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
CloudflareProviderBadge: () => <span>Cloudflare</span>,
OpenStackProviderBadge: () => <span>OpenStack</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
describe("ProviderIconCell", () => {
it("renders the shared provider-type icon for a known provider", async () => {
render(<ProviderIconCell provider="aws" />);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
it("renders a '?' placeholder for a provider type missing from the map", () => {
render(
<ProviderIconCell
provider={"future-provider" as unknown as ProviderType}
/>,
);
expect(screen.getByText("?")).toBeInTheDocument();
});
});
@@ -1,43 +1,10 @@
import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { cn } from "@/lib/utils";
import { ProviderType } from "@/types";
export const PROVIDER_ICONS = {
aws: AWSProviderBadge,
azure: AzureProviderBadge,
gcp: GCPProviderBadge,
kubernetes: KS8ProviderBadge,
m365: M365ProviderBadge,
github: GitHubProviderBadge,
googleworkspace: GoogleWorkspaceProviderBadge,
iac: IacProviderBadge,
image: ImageProviderBadge,
oraclecloud: OracleCloudProviderBadge,
mongodbatlas: MongoDBAtlasProviderBadge,
alibabacloud: AlibabaCloudProviderBadge,
cloudflare: CloudflareProviderBadge,
openstack: OpenStackProviderBadge,
vercel: VercelProviderBadge,
okta: OktaProviderBadge,
} as const;
interface ProviderIconCellProps {
provider: ProviderType;
size?: number;
@@ -49,9 +16,9 @@ export const ProviderIconCell = ({
size = 26,
className = "size-8 rounded-md bg-white",
}: ProviderIconCellProps) => {
const IconComponent = PROVIDER_ICONS[provider];
if (!IconComponent) {
// Unknown provider types (present in the data but missing from the shared
// PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon.
if (!(provider in PROVIDER_TYPE_DATA)) {
return (
<div className={cn("flex items-center justify-center", className)}>
<span className="text-text-neutral-secondary text-xs">?</span>
@@ -66,7 +33,7 @@ export const ProviderIconCell = ({
className,
)}
>
<IconComponent width={size} height={size} />
<ProviderTypeIcon type={provider} size={size} />
</div>
);
};
@@ -6,7 +6,6 @@ import CCCLogo from "./ccc.svg";
import CISLogo from "./cis.svg";
import CISALogo from "./cisa.svg";
import CSALogo from "./csa.svg";
import DORALogo from "./dora.svg";
import ENSLogo from "./ens.png";
import FedRAMPLogo from "./fedramp.svg";
import FFIECLogo from "./ffiec.svg";
@@ -68,9 +67,6 @@ const COMPLIANCE_LOGOS = [
["c5", C5Logo],
["ccc", CCCLogo],
["csa", CSALogo],
// DORA — universal framework (`prowler/compliance/dora.json`). The
// compliance_id is just `dora`, no provider suffix.
["dora", DORALogo],
["secnumcloud", ANSSILogo],
["aws", AWSLogo],
] as const;
-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 170" fill="none">
<defs>
<linearGradient id="doraGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#003399"/>
<stop offset="100%" style="stop-color:#0055A5"/>
</linearGradient>
</defs>
<g>
<rect x="0" y="20" width="400" height="130" rx="16" fill="url(#doraGradient)"/>
<text x="200" y="100" font-family="Helvetica, Arial, sans-serif" font-size="76" font-weight="700" fill="#FFFFFF" text-anchor="middle" letter-spacing="6">DORA</text>
<text x="200" y="135" font-family="Helvetica, Arial, sans-serif" font-size="14" font-weight="500" fill="#FFD700" text-anchor="middle" letter-spacing="3">EU 2022/2554</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 747 B

@@ -0,0 +1,126 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderType } from "@/types/providers";
import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon";
// A provider type the API may return but this UI build does not know about.
const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType;
// Render the lazy provider badges as plain text so we can assert on them.
vi.mock("@/components/icons/providers-badge", () => ({
AWSProviderBadge: () => <span>AWS</span>,
AzureProviderBadge: () => <span>Azure</span>,
GCPProviderBadge: () => <span>GCP</span>,
KS8ProviderBadge: () => <span>Kubernetes</span>,
M365ProviderBadge: () => <span>M365</span>,
GitHubProviderBadge: () => <span>GitHub</span>,
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
IacProviderBadge: () => <span>IaC</span>,
ImageProviderBadge: () => <span>Image</span>,
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
CloudflareProviderBadge: () => <span>Cloudflare</span>,
OpenStackProviderBadge: () => <span>OpenStack</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
// Render the tooltip pieces inline so the hover content is queryable in jsdom.
vi.mock("@/components/shadcn", () => ({
Badge: ({ children }: { children: React.ReactNode }) => (
<span data-testid="badge">{children}</span>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<span data-testid="tooltip">{children}</span>
),
}));
describe("ProviderTypeIcon", () => {
it("renders the badge for the given provider type", async () => {
render(<ProviderTypeIcon type="aws" />);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
it("renders a sized placeholder instead of crashing for an unknown type", () => {
// Regression guard for #9991: an unknown provider type must not throw.
const { container } = render(
<ProviderTypeIcon type={UNKNOWN_TYPE} size={24} />,
);
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
expect(container.querySelector("div")).toHaveStyle({
width: "24px",
height: "24px",
});
});
});
describe("ProviderTypeIconStack", () => {
it("renders one icon per item without deduping by type", async () => {
render(
<ProviderTypeIconStack
items={[
{ key: "a", type: "aws", tooltip: "111" },
{ key: "b", type: "aws", tooltip: "222" },
]}
/>,
);
// Two AWS accounts -> two AWS icons (no dedupe).
expect(await screen.findAllByText("AWS")).toHaveLength(2);
});
it("shows each item's tooltip text on the icon", async () => {
render(
<ProviderTypeIconStack
items={[{ key: "a", type: "aws", tooltip: "account-uid-123" }]}
/>,
);
expect(await screen.findByTestId("tooltip")).toHaveTextContent(
"account-uid-123",
);
});
it("collapses items beyond `max` into a +N badge", async () => {
render(
<ProviderTypeIconStack
max={3}
items={[
{ key: "a", type: "aws", tooltip: "1" },
{ key: "b", type: "azure", tooltip: "2" },
{ key: "c", type: "gcp", tooltip: "3" },
{ key: "d", type: "github", tooltip: "4" },
{ key: "e", type: "okta", tooltip: "5" },
]}
/>,
);
expect(await screen.findByTestId("badge")).toHaveTextContent("+2");
// First icon is shown; items sliced beyond `max` never reach the DOM.
expect(await screen.findByText("AWS")).toBeInTheDocument();
expect(screen.queryByText("Okta")).not.toBeInTheDocument();
});
it("renders known icons and skips unknown types without crashing", async () => {
// Regression guard for #9991: an unknown type in the stack must not throw.
render(
<ProviderTypeIconStack
items={[
{ key: "a", type: "aws", tooltip: "111" },
{ key: "b", type: UNKNOWN_TYPE, tooltip: "222" },
]}
/>,
);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
});
@@ -0,0 +1,250 @@
"use client";
import { type ComponentType, lazy, Suspense } from "react";
import {
Badge,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { cn } from "@/lib/utils";
import type { ProviderType } from "@/types/providers";
type IconProps = { width: number; height: number };
const IconPlaceholder = ({ width, height }: IconProps) => (
<div style={{ width, height }} />
);
// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred
// chunk instead of being eagerly bundled wherever a selector is imported.
const AWSProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AWSProviderBadge,
})),
);
const AzureProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AzureProviderBadge,
})),
);
const GCPProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GCPProviderBadge,
})),
);
const KS8ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.KS8ProviderBadge,
})),
);
const M365ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.M365ProviderBadge,
})),
);
const GitHubProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GitHubProviderBadge,
})),
);
const GoogleWorkspaceProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GoogleWorkspaceProviderBadge,
})),
);
const IacProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.IacProviderBadge,
})),
);
const ImageProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.ImageProviderBadge,
})),
);
const OracleCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OracleCloudProviderBadge,
})),
);
const MongoDBAtlasProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.MongoDBAtlasProviderBadge,
})),
);
const AlibabaCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AlibabaCloudProviderBadge,
})),
);
const CloudflareProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.CloudflareProviderBadge,
})),
);
const OpenStackProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OpenStackProviderBadge,
})),
);
const VercelProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.VercelProviderBadge,
})),
);
const OktaProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OktaProviderBadge,
})),
);
/**
* Single source of truth mapping each provider type to its human-readable
* label and (lazy) badge component. Shared by the account and provider-type
* selectors so both stay in sync on labels, icons, and sizing.
*/
export const PROVIDER_TYPE_DATA: Record<
ProviderType,
{ label: string; icon: ComponentType<IconProps> }
> = {
aws: { label: "Amazon Web Services", icon: AWSProviderBadge },
azure: { label: "Microsoft Azure", icon: AzureProviderBadge },
gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge },
kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge },
m365: { label: "Microsoft 365", icon: M365ProviderBadge },
github: { label: "GitHub", icon: GitHubProviderBadge },
googleworkspace: {
label: "Google Workspace",
icon: GoogleWorkspaceProviderBadge,
},
iac: { label: "Infrastructure as Code", icon: IacProviderBadge },
image: { label: "Container Registry", icon: ImageProviderBadge },
oraclecloud: {
label: "Oracle Cloud Infrastructure",
icon: OracleCloudProviderBadge,
},
mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge },
alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge },
cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge },
openstack: { label: "OpenStack", icon: OpenStackProviderBadge },
vercel: { label: "Vercel", icon: VercelProviderBadge },
okta: { label: "Okta", icon: OktaProviderBadge },
};
interface ProviderTypeIconProps {
type: ProviderType;
size?: number;
}
/**
* Renders a single provider-type badge with a sized placeholder fallback.
*
* Falls back to the placeholder for provider types missing from
* `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI
* build does not). The `type` is statically typed as `ProviderType`, so this
* only guards the runtime case see #9991, which fixed the same crash class.
*/
export const ProviderTypeIcon = ({
type,
size = 18,
}: ProviderTypeIconProps) => {
const data = PROVIDER_TYPE_DATA[type];
if (!data) return <IconPlaceholder width={size} height={size} />;
const Icon = data.icon;
return (
<Suspense fallback={<IconPlaceholder width={size} height={size} />}>
<Icon width={size} height={size} />
</Suspense>
);
};
export interface ProviderTypeIconStackItem {
/** Stable React key (account id for accounts, provider type for types). */
key: string;
type: ProviderType;
/** Text shown on hover to disambiguate the icon (e.g. an account UID). */
tooltip?: string;
}
interface ProviderTypeIconStackProps {
items: ProviderTypeIconStackItem[];
max?: number;
size?: number;
className?: string;
}
/**
* Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a
* Radix portal, so the tooltip is not clipped by the selector trigger and we do
* not need to portal it ourselves. `delayDuration` is set on the tooltip itself
* because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider`
* (delay 0), which would otherwise override an ancestor provider's delay.
*/
const IconWithTooltip = ({
item,
size,
}: {
item: ProviderTypeIconStackItem;
size: number;
}) => {
const icon = (
<span className="inline-flex shrink-0">
<ProviderTypeIcon type={item.type} size={size} />
</span>
);
if (!item.tooltip) return icon;
return (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{icon}</TooltipTrigger>
<TooltipContent side="top">{item.tooltip}</TooltipContent>
</Tooltip>
);
};
/**
* Renders up to `max` provider-type icons followed by a `+N` badge for the
* remainder. Each icon shows its `tooltip` on hover. Items are rendered as
* passed (one per selection) callers decide whether to dedupe.
*/
export const ProviderTypeIconStack = ({
items,
max = 3,
size = 18,
className,
}: ProviderTypeIconStackProps) => {
const visible = items.slice(0, max);
const overflow = items.slice(max);
const overflowLabel = overflow
.map((item) => item.tooltip)
.filter(Boolean)
.join(", ");
return (
<span className={cn("flex shrink-0 items-center gap-1", className)}>
<span className="flex items-center gap-1">
{visible.map((item) => (
<IconWithTooltip key={item.key} item={item} size={size} />
))}
</span>
{overflow.length > 0 && (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>
<Badge variant="tag" className="px-1.5 py-0.5 text-xs font-medium">
+{overflow.length}
</Badge>
</TooltipTrigger>
{overflowLabel && (
<TooltipContent side="top" className="max-w-xs">
{overflowLabel}
</TooltipContent>
)}
</Tooltip>
)}
</span>
);
};
@@ -8,7 +8,10 @@ import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
@@ -279,11 +282,14 @@ export const S3IntegrationForm = ({
// Show configuration step (step 0 or editing configuration)
if (isEditingConfig || currentStep === 0) {
const providerOptions = providers.map((provider) => {
const Icon = PROVIDER_ICONS[provider.attributes.provider];
const providerType = provider.attributes.provider;
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
description: provider.attributes.connection.connected
? "Connected"
: "Disconnected",
@@ -10,7 +10,10 @@ import { useEffect, useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
@@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({
? "Connected"
: "Disconnected";
const Icon = PROVIDER_ICONS[provider.attributes.provider];
const providerType = provider.attributes.provider;
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
description: isDisabled
? `${connectionLabel} (Already in use)`
: connectionLabel,
@@ -0,0 +1,92 @@
"use client";
import Link from "next/link";
import { InfoIcon } from "@/components/icons/Icons";
import { Button, Card, CardContent } from "@/components/shadcn";
import { cn } from "@/lib/utils";
const NO_PROVIDERS_ADDED_ACTION = {
BUTTON: "button",
LINK: "link",
} as const;
interface NoProvidersAddedBaseProps {
containerClassName?: string;
}
interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps {
action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON;
onOpenWizard: () => void;
href?: never;
}
interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps {
action: typeof NO_PROVIDERS_ADDED_ACTION.LINK;
href: string;
onOpenWizard?: never;
}
type NoProvidersAddedProps =
| NoProvidersAddedButtonProps
| NoProvidersAddedLinkProps;
const renderCta = (props: NoProvidersAddedProps) => {
if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) {
return (
<Button
asChild
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
>
<Link href={props.href}>Get Started</Link>
</Button>
);
}
return (
<Button
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={props.onOpenWizard}
>
Get Started
</Button>
);
};
export const NoProvidersAdded = (props: NoProvidersAddedProps) => {
return (
<div
role="region"
aria-labelledby="no-providers-added-title"
className={cn(
"flex min-h-[calc(100dvh-10rem)] items-center justify-center",
props.containerClassName,
)}
>
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2
id="no-providers-added-title"
className="text-2xl font-bold text-gray-800 dark:text-white"
>
No Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No providers have been configured. Start by setting up a provider.
</p>
</div>
{renderCta(props)}
</CardContent>
</Card>
</div>
);
};
@@ -1,11 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
refreshMock: vi.fn(),
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/providers",
useRouter: () => ({
refresh: refreshMock,
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/providers/table", () => ({
SkeletonTableProviders: () => <div data-testid="providers-skeleton" />,
}));
vi.mock("@/components/providers/add-provider-button", () => ({
AddProviderButton: () => <button type="button">Add provider</button>,
AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => (
<button type="button" onClick={onOpenWizard}>
Add Provider
</button>
),
}));
vi.mock("@/components/providers/muted-findings-config-button", () => ({
@@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
}));
vi.mock("@/components/providers/providers-filters", () => ({
ProvidersFilters: () => <div data-testid="providers-filters">Filters</div>,
ProvidersFilters: ({ actions }: { actions: ReactNode }) => (
<div data-testid="providers-filters">
Filters
{actions}
</div>
),
}));
vi.mock("@/components/providers/providers-accounts-table", () => ({
@@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
ProviderWizardModal: ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div role="dialog">
Provider wizard
<button type="button" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
}));
import { ProvidersAccountsView } from "./providers-accounts-view";
@@ -36,8 +80,55 @@ const metadata: MetaDataProps = {
version: "latest",
};
const disconnectedProviders: ProviderProps[] = [
{
id: "provider-1",
type: "providers",
attributes: {
provider: "aws",
uid: "123456789012",
alias: "Production",
status: "completed",
resources: 0,
connection: {
connected: false,
last_checked_at: "2026-04-13T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-13T00:00:00Z",
updated_at: "2026-04-13T00:00:00Z",
created_by: {
object: "user",
id: "user-1",
},
},
relationships: {
secret: {
data: null,
},
provider_groups: {
meta: {
count: 0,
},
data: [],
},
},
},
];
describe("ProvidersAccountsView", () => {
it("keeps the same vertical spacing between filters and table as other views", () => {
afterEach(() => {
vi.restoreAllMocks();
searchParamsValue.current = "";
window.history.replaceState({}, "", "/");
});
it("shows a full page empty state without filters or table when there are no providers", () => {
// Given/When
render(
<ProvidersAccountsView
isCloud={false}
@@ -48,11 +139,170 @@ describe("ProvidersAccountsView", () => {
/>,
);
// Then
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
expect(
screen.getByRole("region", { name: /no providers configured/i }),
).toHaveClass("min-h-[calc(100dvh-28rem)]");
expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument();
expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument();
});
it("opens the provider wizard from the no providers CTA", async () => {
// Given
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => {
// Given
searchParamsValue.current = "tab=connected&addProvider=true";
window.history.replaceState(
{},
"",
"/providers?tab=connected&addProvider=true",
);
// Spy only after the URL setup so we measure what the component does on mount.
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
expect(replaceStateSpy).not.toHaveBeenCalled();
});
it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => {
// Given
searchParamsValue.current = "tab=connected&addProvider=true";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /close/i }));
// Then
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
// The URL is cleaned via the History API (no RSC refetch). We must NOT
// refresh/replace here: re-running the /providers Server Component on close
// read as a full page reload. The provider-creation actions already
// revalidatePath("/providers"), so the table is fresh behind the modal.
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
"/providers?tab=connected",
);
expect(refreshMock).not.toHaveBeenCalled();
expect(replaceMock).not.toHaveBeenCalled();
});
it("does not touch the URL or refetch when a manually opened wizard closes", async () => {
// Given: no addProvider param in the URL, wizard opened via the CTA.
searchParamsValue.current = "";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When: open the wizard from the empty-state CTA, then close it.
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
await user.click(screen.getByRole("button", { name: /close/i }));
// Then: nothing to clean and no refresh — the creation actions own the
// data refresh via revalidatePath.
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(replaceStateSpy).not.toHaveBeenCalled();
expect(refreshMock).not.toHaveBeenCalled();
expect(replaceMock).not.toHaveBeenCalled();
});
it("keeps filters and table visible when providers are disconnected", () => {
// Given/When
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
/>,
);
// Then
expect(screen.getByTestId("providers-filters").parentElement).toHaveClass(
"flex",
"flex-col",
"gap-6",
);
expect(screen.getByTestId("providers-table")).toBeInTheDocument();
expect(
screen.queryByText("No Providers Configured"),
).not.toBeInTheDocument();
});
it("opens the provider wizard from the normal Add Provider button", async () => {
// Given
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /add provider/i }));
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
});
@@ -1,9 +1,11 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useState } from "react";
import { AddProviderButton } from "@/components/providers/add-provider-button";
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
import { ProvidersFilters } from "@/components/providers/providers-filters";
import { ProviderWizardModal } from "@/components/providers/wizard";
@@ -11,6 +13,10 @@ import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import {
ADD_PROVIDER_SEARCH_PARAM,
ADD_PROVIDER_SEARCH_VALUE,
} from "@/lib/providers-navigation";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
@@ -29,7 +35,14 @@ export function ProvidersAccountsView({
providers,
rows,
}: ProvidersAccountsViewProps) {
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const pathname = usePathname();
const searchParams = useSearchParams();
const hasNoProviders = providers.length === 0;
const shouldOpenProviderWizardFromUrl =
searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE;
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(
() => shouldOpenProviderWizardFromUrl,
);
const [providerWizardInitialData, setProviderWizardInitialData] = useState<
ProviderWizardInitialData | undefined
>(undefined);
@@ -52,38 +65,64 @@ export function ProvidersAccountsView({
const handleWizardOpenChange = (open: boolean) => {
setIsProviderWizardOpen(open);
if (!open) {
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(undefined);
if (open) return;
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(undefined);
// Only clean the one-shot ?addProvider intent from the URL bar, via the
// History API so it does NOT trigger an RSC refetch. We must not refresh
// here: the provider-creation actions (addProvider / addCredentialsProvider
// / checkConnectionProvider) already revalidatePath("/providers"), so the
// table updates behind the modal. A router.refresh()/replace() on close
// re-ran the whole /providers Server Component, which read as a full reload.
if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(ADD_PROVIDER_SEARCH_PARAM);
const query = params.toString();
window.history.replaceState(
null,
"",
query ? `${pathname}?${query}` : pathname,
);
}
};
return (
<>
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
{hasNoProviders ? (
<NoProvidersAdded
action="button"
containerClassName="min-h-[calc(100dvh-28rem)]"
onOpenWizard={() => openProviderWizard()}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
) : (
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={handleWizardOpenChange}
initialData={providerWizardInitialData}
orgInitialData={orgWizardInitialData}
refreshOnClose={false}
/>
</>
);
@@ -50,6 +50,10 @@ interface UseProviderWizardControllerProps {
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
// When false, the caller skips the post-close router.refresh() and relies on
// the provider-creation actions' revalidatePath("/providers") to refresh the
// data. Defaults to true so standalone callers keep refreshing.
refreshOnClose?: boolean;
}
export function useProviderWizardController({
@@ -57,6 +61,7 @@ export function useProviderWizardController({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose = true,
}: UseProviderWizardControllerProps) {
const router = useRouter();
const initialProviderId = initialData?.providerId ?? null;
@@ -185,7 +190,9 @@ export function useProviderWizardController({
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
router.refresh();
if (refreshOnClose) {
router.refresh();
}
};
const handleDialogOpenChange = (nextOpen: boolean) => {
@@ -38,6 +38,7 @@ interface ProviderWizardModalProps {
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
refreshOnClose?: boolean;
}
export function ProviderWizardModal({
@@ -45,6 +46,7 @@ export function ProviderWizardModal({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose,
}: ProviderWizardModalProps) {
const {
backToProviderFlow,
@@ -72,6 +74,7 @@ export function ProviderWizardModal({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose,
});
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
const { containerRef, sentinelRef, showScrollHint } = useScrollHint({
@@ -1,38 +0,0 @@
"use client";
import { Button, Card, CardContent } from "@/components/shadcn";
import { InfoIcon } from "../icons/Icons";
interface NoProvidersAddedProps {
onOpenWizard: () => void;
}
export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => (
<div className="flex min-h-screen items-center justify-center">
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
No Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No providers have been configured. Start by setting up a provider.
</p>
</div>
<Button
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={onOpenWizard}
>
Get Started
</Button>
</CardContent>
</Card>
</div>
);
@@ -1,73 +1,43 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: ({ open }: { open: boolean }) =>
open ? <div role="dialog">Provider wizard</div> : null,
}));
vi.mock("./no-providers-connected", () => ({
NoProvidersConnected: () => <div>No Connected Providers</div>,
}));
describe("ScansProvidersEmptyState", () => {
afterEach(() => {
vi.clearAllMocks();
searchParamsValue.current = "";
});
it("shows the add provider message and opens the provider wizard", async () => {
const user = userEvent.setup();
it("shows the add provider message with a providers page CTA", () => {
// Given/When
render(<ScansProvidersEmptyState thereIsNoProviders />);
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("clears the launch scan URL intent before opening the provider wizard", async () => {
// Given
searchParamsValue.current = "tab=completed&launchScan=true";
const user = userEvent.setup();
render(<ScansProvidersEmptyState thereIsNoProviders />);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
scroll: false,
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
const cta = screen.getByRole("link", {
name: /open add provider modal/i,
});
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF);
expect(cta.tagName).toBe("A");
});
it("does not render the provider wizard in Scans", () => {
// Given/When
render(<ScansProvidersEmptyState thereIsNoProviders />);
// Then
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("shows the no connected providers message", () => {
// Given/When
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
// Then
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
@@ -1,12 +1,6 @@
"use client";
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
import { NoProvidersAdded } from "./no-providers-added";
import { NoProvidersConnected } from "./no-providers-connected";
interface ScansProvidersEmptyStateProps {
@@ -16,35 +10,13 @@ interface ScansProvidersEmptyStateProps {
export function ScansProvidersEmptyState({
thereIsNoProviders,
}: ScansProvidersEmptyStateProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const openProviderWizard = () => {
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
}
setIsProviderWizardOpen(true);
};
return (
<>
{thereIsNoProviders ? (
<NoProvidersAdded onOpenWizard={openProviderWizard} />
<NoProvidersAdded action="link" href={ADD_PROVIDER_HREF} />
) : (
<NoProvidersConnected />
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={setIsProviderWizardOpen}
/>
</>
);
}
@@ -0,0 +1,159 @@
import { Row } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
// The forms pull in server actions (`@/actions/users/users`) that can't run in
// jsdom, so stub them with identifiable markers to assert which modal opens.
vi.mock("../forms", () => ({
DeleteForm: ({ userId }: { userId: string }) => (
<div data-testid="delete-form">delete-form:{userId}</div>
),
EditForm: ({ userId }: { userId: string }) => (
<div data-testid="edit-form">edit-form:{userId}</div>
),
ExpelUserForm: ({ userId }: { userId: string }) => (
<div data-testid="expel-form">expel-form:{userId}</div>
),
}));
import { DataTableRowActions } from "./data-table-row-actions";
interface RowOptions {
id?: string;
isCurrentUser?: boolean;
canBeExpelled?: boolean;
currentTenantId?: string;
}
const createRow = ({
id = "user-1",
isCurrentUser,
canBeExpelled,
currentTenantId,
}: RowOptions = {}) =>
({
original: {
id,
attributes: {
name: "Jane Doe",
email: "jane@example.com",
company_name: "Acme",
role: { name: "admin" },
},
isCurrentUser,
canBeExpelled,
currentTenantId,
},
}) as unknown as Row<{ id: string }>;
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: "Open actions menu" }));
};
describe("DataTableRowActions (users)", () => {
it("always renders the Edit User action", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow()} />);
await openMenu(user);
expect(screen.getByText("Edit User")).toBeInTheDocument();
});
it("shows Delete User only for the current user's row", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
await openMenu(user);
expect(screen.getByText("Delete User")).toBeInTheDocument();
expect(screen.getByText("Danger zone")).toBeInTheDocument();
});
it("does NOT show Delete User for another user's row", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({ isCurrentUser: false })} />);
await openMenu(user);
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
});
it("does NOT show Delete User when isCurrentUser is undefined", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({})} />);
await openMenu(user);
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
});
it("hides the Danger zone entirely when the user can neither be deleted nor expelled", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow({ isCurrentUser: false, canBeExpelled: false })}
/>,
);
await openMenu(user);
// Only the non-destructive Edit action remains.
expect(screen.getByText("Edit User")).toBeInTheDocument();
expect(screen.queryByText("Danger zone")).not.toBeInTheDocument();
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
expect(
screen.queryByText("Expel from organization"),
).not.toBeInTheDocument();
});
it("shows Expel but not Delete User for an expellable, non-current user", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow({
isCurrentUser: false,
canBeExpelled: true,
currentTenantId: "tenant-1",
})}
/>,
);
await openMenu(user);
expect(screen.getByText("Danger zone")).toBeInTheDocument();
expect(screen.getByText("Expel from organization")).toBeInTheDocument();
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
});
it("renders Delete User with destructive styling", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
await openMenu(user);
const menuItem = screen
.getByText("Delete User")
.closest("[role='menuitem']");
expect(menuItem).toBeInTheDocument();
expect(menuItem).toHaveClass("text-text-error-primary");
});
it("opens the delete confirmation modal when Delete User is selected", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow({ id: "user-42", isCurrentUser: true })}
/>,
);
await openMenu(user);
await user.click(screen.getByText("Delete User"));
expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument();
expect(screen.getByTestId("delete-form")).toHaveTextContent(
"delete-form:user-42",
);
});
});
@@ -29,6 +29,7 @@ interface UserRowData {
attributes?: UserRowAttributes;
canBeExpelled?: boolean;
currentTenantId?: string;
isCurrentUser?: boolean;
}
interface DataTableRowActionsProps<UserProps extends UserRowData> {
@@ -57,6 +58,10 @@ export function DataTableRowActions<UserProps extends UserRowData>({
row.original.canBeExpelled === true && !!row.original.currentTenantId;
const currentTenantId = row.original.currentTenantId;
// A user can only delete their own account (enforced by the backend), so the
// delete action is shown exclusively for the current user's row.
const canDeleteUser = row.original.isCurrentUser === true;
return (
<>
<Modal
@@ -74,14 +79,16 @@ export function DataTableRowActions<UserProps extends UserRowData>({
setIsOpen={setIsEditOpen}
/>
</Modal>
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
>
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
</Modal>
{canDeleteUser && (
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
>
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
</Modal>
)}
{canExpelUser && currentTenantId && (
<Modal
open={isExpelOpen}
@@ -104,22 +111,26 @@ export function DataTableRowActions<UserProps extends UserRowData>({
label="Edit User"
onSelect={() => setIsEditOpen(true)}
/>
<ActionDropdownDangerZone>
{canExpelUser && (
<ActionDropdownItem
icon={<UserMinus aria-hidden="true" />}
label="Expel from organization"
destructive
onSelect={() => setIsExpelOpen(true)}
/>
)}
<ActionDropdownItem
icon={<Trash2 aria-hidden="true" />}
label="Delete User"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
</ActionDropdownDangerZone>
{(canExpelUser || canDeleteUser) && (
<ActionDropdownDangerZone>
{canExpelUser && (
<ActionDropdownItem
icon={<UserMinus aria-hidden="true" />}
label="Expel from organization"
destructive
onSelect={() => setIsExpelOpen(true)}
/>
)}
{canDeleteUser && (
<ActionDropdownItem
icon={<Trash2 aria-hidden="true" />}
label="Delete User"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
)}
</ActionDropdownDangerZone>
)}
</ActionDropdown>
</div>
</>

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