mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-09 21:04:53 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21e7f29153 | |||
| de51eed96c | |||
| 835dbddc6a | |||
| 103761f146 | |||
| 0e6268e159 | |||
| f48984e6a1 | |||
| 6df80a4890 |
@@ -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"
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
-40
@@ -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": ""
|
||||
}
|
||||
-22
@@ -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
@@ -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):
|
||||
|
||||
-229
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user