diff --git a/.env b/.env
index 0e3a9d5610..734b9df42a 100644
--- a/.env
+++ b/.env
@@ -48,6 +48,26 @@ POSTGRES_DB=prowler_db
# POSTGRES_REPLICA_MAX_ATTEMPTS=3
# POSTGRES_REPLICA_RETRY_BASE_DELAY=0.5
+# Neo4j auth
+NEO4J_HOST=neo4j
+NEO4J_PORT=7687
+NEO4J_USER=neo4j
+NEO4J_PASSWORD=neo4j_password
+# Neo4j settings
+NEO4J_DBMS_MAX__DATABASES=1000
+NEO4J_SERVER_MEMORY_PAGECACHE_SIZE=1G
+NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE=1G
+NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE=1G
+NEO4J_POC_EXPORT_FILE_ENABLED=true
+NEO4J_APOC_IMPORT_FILE_ENABLED=true
+NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true
+NEO4J_PLUGINS=["apoc"]
+NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST=apoc.*
+NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED=apoc.*
+NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687
+# Neo4j Prowler settings
+ATTACK_PATHS_FINDINGS_BATCH_SIZE=1000
+
# Celery-Prowler task settings
TASK_RETRY_DELAY_SECONDS=0.1
TASK_RETRY_ATTEMPTS=5
@@ -117,7 +137,6 @@ SENTRY_ENVIRONMENT=local
SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
-
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.16.0
diff --git a/.github/actions/setup-python-poetry/action.yml b/.github/actions/setup-python-poetry/action.yml
index 790f3ef0e6..cb17c050a2 100644
--- a/.github/actions/setup-python-poetry/action.yml
+++ b/.github/actions/setup-python-poetry/action.yml
@@ -29,7 +29,7 @@ runs:
run: |
BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
echo "Using branch: $BRANCH_NAME"
- sed -i "s|@master|@$BRANCH_NAME|g" pyproject.toml
+ sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml
- name: Install poetry
shell: bash
diff --git a/.github/labeler.yml b/.github/labeler.yml
index fa2c7981f9..7e87c1a6a3 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -46,12 +46,17 @@ provider/oci:
- changed-files:
- any-glob-to-any-file: "prowler/providers/oraclecloud/**"
- any-glob-to-any-file: "tests/providers/oraclecloud/**"
-
+
provider/alibabacloud:
- changed-files:
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
+provider/cloudflare:
+ - changed-files:
+ - any-glob-to-any-file: "prowler/providers/cloudflare/**"
+ - any-glob-to-any-file: "tests/providers/cloudflare/**"
+
github_actions:
- changed-files:
- any-glob-to-any-file: ".github/workflows/*"
@@ -67,15 +72,21 @@ mutelist:
- any-glob-to-any-file: "prowler/providers/azure/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/gcp/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/kubernetes/lib/mutelist/**"
+ - any-glob-to-any-file: "prowler/providers/m365/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/mongodbatlas/lib/mutelist/**"
+ - any-glob-to-any-file: "prowler/providers/oraclecloud/lib/mutelist/**"
+ - any-glob-to-any-file: "prowler/providers/alibabacloud/lib/mutelist/**"
+ - any-glob-to-any-file: "prowler/providers/cloudflare/lib/mutelist/**"
- any-glob-to-any-file: "tests/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/aws/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/azure/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/gcp/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/kubernetes/lib/mutelist/**"
+ - any-glob-to-any-file: "tests/providers/m365/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/mongodbatlas/lib/mutelist/**"
- - any-glob-to-any-file: "tests/providers/oci/lib/mutelist/**"
+ - any-glob-to-any-file: "tests/providers/oraclecloud/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/alibabacloud/lib/mutelist/**"
+ - any-glob-to-any-file: "tests/providers/cloudflare/lib/mutelist/**"
integration/s3:
- changed-files:
diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml
index 285262ce7f..e8a8efc879 100644
--- a/.github/workflows/api-security.yml
+++ b/.github/workflows/api-security.yml
@@ -61,7 +61,8 @@ jobs:
- name: Safety
if: steps.check-changes.outputs.any_changed == 'true'
- run: poetry run safety check
+ run: poetry run safety check --ignore 79023,79027
+ # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
- name: Vulture
if: steps.check-changes.outputs.any_changed == 'true'
diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml
index 4a1f8e502a..cc22767552 100644
--- a/.github/workflows/pr-check-changelog.yml
+++ b/.github/workflows/pr-check-changelog.yml
@@ -42,14 +42,16 @@ jobs:
ui/**
prowler/**
mcp_server/**
+ poetry.lock
+ pyproject.toml
- name: Check for folder changes and changelog presence
id: check-folders
run: |
missing_changelogs=""
- # Check api folder
if [[ "${{ steps.changed-files.outputs.any_changed }}" == "true" ]]; then
+ # Check monitored folders
for folder in $MONITORED_FOLDERS; do
# Get files changed in this folder
changed_in_folder=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep "^${folder}/" || true)
@@ -64,6 +66,22 @@ jobs:
fi
fi
done
+
+ # Check root-level dependency files (poetry.lock, pyproject.toml)
+ # These are associated with the prowler folder changelog
+ root_deps_changed=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep -E "^(poetry\.lock|pyproject\.toml)$" || true)
+ if [ -n "$root_deps_changed" ]; then
+ echo "Detected changes in root dependency files: $root_deps_changed"
+ # Check if prowler/CHANGELOG.md was already updated (might have been caught above)
+ prowler_changelog_updated=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep "^prowler/CHANGELOG.md$" || true)
+ if [ -z "$prowler_changelog_updated" ]; then
+ # Only add if prowler wasn't already flagged
+ if ! echo "$missing_changelogs" | grep -q "prowler"; then
+ echo "No changelog update found for root dependency changes"
+ missing_changelogs="${missing_changelogs}- \`prowler\` (root dependency files changed)"$'\n'
+ fi
+ fi
+ fi
fi
{
diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml
index 054bd69f6e..6dcc2f73af 100644
--- a/.github/workflows/ui-e2e-tests.yml
+++ b/.github/workflows/ui-e2e-tests.yml
@@ -116,7 +116,7 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
- node-version: '20.x'
+ node-version: '24.13.0'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
@@ -125,16 +125,20 @@ jobs:
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- - name: Setup pnpm cache
+ - name: Setup pnpm and Next.js cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
- path: ${{ env.STORE_PATH }}
- key: ${{ runner.os }}-pnpm-store-${{ hashFiles('ui/pnpm-lock.yaml') }}
+ path: |
+ ${{ env.STORE_PATH }}
+ ./ui/node_modules
+ ./ui/.next/cache
+ key: ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-${{ hashFiles('ui/**/*.ts', 'ui/**/*.tsx', 'ui/**/*.js', 'ui/**/*.jsx') }}
restore-keys: |
- ${{ runner.os }}-pnpm-store-
+ ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-
+ ${{ runner.os }}-pnpm-nextjs-
- name: Install UI dependencies
working-directory: ./ui
- run: pnpm install --frozen-lockfile
+ run: pnpm install --frozen-lockfile --prefer-offline
- name: Build UI application
working-directory: ./ui
run: pnpm run build
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index e657e56960..e7d1834f91 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -16,7 +16,7 @@ concurrency:
env:
UI_WORKING_DIR: ./ui
- NODE_VERSION: '20.x'
+ NODE_VERSION: '24.13.0'
jobs:
ui-tests:
@@ -62,18 +62,22 @@ jobs:
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- - name: Setup pnpm cache
+ - name: Setup pnpm and Next.js cache
if: steps.check-changes.outputs.any_changed == 'true'
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
- path: ${{ env.STORE_PATH }}
- key: ${{ runner.os }}-pnpm-store-${{ hashFiles('ui/pnpm-lock.yaml') }}
+ path: |
+ ${{ env.STORE_PATH }}
+ ${{ env.UI_WORKING_DIR }}/node_modules
+ ${{ env.UI_WORKING_DIR }}/.next/cache
+ key: ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-${{ hashFiles('ui/**/*.ts', 'ui/**/*.tsx', 'ui/**/*.js', 'ui/**/*.jsx') }}
restore-keys: |
- ${{ runner.os }}-pnpm-store-
+ ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-
+ ${{ runner.os }}-pnpm-nextjs-
- name: Install dependencies
if: steps.check-changes.outputs.any_changed == 'true'
- run: pnpm install --frozen-lockfile
+ run: pnpm install --frozen-lockfile --prefer-offline
- name: Run healthcheck
if: steps.check-changes.outputs.any_changed == 'true'
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 24f8f0f211..0a4164bc43 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -42,7 +42,7 @@ repos:
"--remove-unused-variable",
]
- - repo: https://github.com/timothycrosley/isort
+ - repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
@@ -120,7 +120,8 @@ repos:
name: safety
description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities"
# TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X
- entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745'
+ # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
+ entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027'
language: system
- id: vulture
diff --git a/AGENTS.md b/AGENTS.md
index 62ebb587f1..e203deb845 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,6 +20,7 @@ Use these skills for detailed patterns on-demand:
| `playwright` | Page Object Model, MCP workflow, selectors | [SKILL.md](skills/playwright/SKILL.md) |
| `pytest` | Fixtures, mocking, markers, parametrize | [SKILL.md](skills/pytest/SKILL.md) |
| `django-drf` | ViewSets, Serializers, Filters | [SKILL.md](skills/django-drf/SKILL.md) |
+| `jsonapi` | Strict JSON:API v1.1 spec compliance | [SKILL.md](skills/jsonapi/SKILL.md) |
| `zod-4` | New API (z.email(), z.uuid()) | [SKILL.md](skills/zod-4/SKILL.md) |
| `zustand-5` | Persist, selectors, slices | [SKILL.md](skills/zustand-5/SKILL.md) |
| `ai-sdk-5` | UIMessage, streaming, LangChain | [SKILL.md](skills/ai-sdk-5/SKILL.md) |
@@ -38,7 +39,9 @@ Use these skills for detailed patterns on-demand:
| `prowler-compliance` | Compliance framework structure | [SKILL.md](skills/prowler-compliance/SKILL.md) |
| `prowler-compliance-review` | Review compliance framework PRs | [SKILL.md](skills/prowler-compliance-review/SKILL.md) |
| `prowler-provider` | Add new cloud providers | [SKILL.md](skills/prowler-provider/SKILL.md) |
+| `prowler-changelog` | Changelog entries (keepachangelog.com) | [SKILL.md](skills/prowler-changelog/SKILL.md) |
| `prowler-ci` | CI checks and PR gates (GitHub Actions) | [SKILL.md](skills/prowler-ci/SKILL.md) |
+| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) |
| `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) |
| `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) |
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
@@ -49,13 +52,20 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
|--------|-------|
+| Add changelog entry for a PR or feature | `prowler-changelog` |
+| Adding DRF pagination or permissions | `django-drf` |
| Adding new providers | `prowler-provider` |
| Adding services to existing providers | `prowler-provider` |
| After creating/modifying a skill | `skill-sync` |
| App Router / Server Actions | `nextjs-15` |
| Building AI chat features | `ai-sdk-5` |
+| Committing changes | `prowler-commit` |
+| Create PR that requires changelog entry | `prowler-changelog` |
| Create a PR with gh pr create | `prowler-pr` |
+| Creating API endpoints | `jsonapi` |
+| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
| Creating Zod schemas | `zod-4` |
+| Creating a git commit | `prowler-commit` |
| Creating new checks | `prowler-sdk-check` |
| Creating new skills | `skill-creator` |
| Creating/modifying Prowler UI components | `prowler-ui` |
@@ -64,13 +74,16 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Debug why a GitHub Actions job is failing | `prowler-ci` |
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
| General Prowler development questions | `prowler` |
-| Generic DRF patterns | `django-drf` |
+| Implementing JSON:API endpoints | `django-drf` |
| Inspect PR CI checks and gates (.github/workflows/*) | `prowler-ci` |
| Inspect PR CI workflows (.github/workflows/*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` |
| Mapping checks to compliance controls | `prowler-compliance` |
| Mocking AWS with moto in tests | `prowler-test-sdk` |
+| Modifying API responses | `jsonapi` |
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
+| Review changelog format and conventions | `prowler-changelog` |
+| Reviewing JSON:API compliance | `jsonapi` |
| Reviewing compliance framework PRs | `prowler-compliance-review` |
| Testing RLS tenant isolation | `prowler-test-api` |
| Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` |
@@ -78,6 +91,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Understand PR title conventional-commit validation | `prowler-ci` |
| Understand changelog gate and no-changelog label behavior | `prowler-ci` |
| Understand review ownership with CODEOWNERS | `prowler-pr` |
+| Update CHANGELOG.md in any component | `prowler-changelog` |
| Updating existing checks and metadata | `prowler-sdk-check` |
| Using Zustand stores | `zustand-5` |
| Working on MCP server tools | `prowler-mcp` |
diff --git a/README.md b/README.md
index bdf08ee20a..5bb96eaca5 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,23 @@ prowler dashboard
```

+
+## Attack Paths
+
+Attack Paths automatically extends every completed AWS scan with a Neo4j graph that combines Cartography's cloud inventory with Prowler findings. The feature runs in the API worker after each scan and therefore requires:
+
+- An accessible Neo4j instance (the Docker Compose files already ships a `neo4j` service).
+- The following environment variables so Django and Celery can connect:
+
+ | Variable | Description | Default |
+ | --- | --- | --- |
+ | `NEO4J_HOST` | Hostname used by the API containers. | `neo4j` |
+ | `NEO4J_PORT` | Bolt port exposed by Neo4j. | `7687` |
+ | `NEO4J_USER` / `NEO4J_PASSWORD` | Credentials with rights to create per-tenant databases. | `neo4j` / `neo4j_password` |
+
+Every AWS provider scan will enqueue an Attack Paths ingestion job automatically. Other cloud providers will be added in future iterations.
+
+
# Prowler at a Glance
> [!Tip]
> For the most accurate and up-to-date information about checks, services, frameworks, and categories, visit [**Prowler Hub**](https://hub.prowler.com).
@@ -87,16 +104,17 @@ prowler dashboard
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface |
|---|---|---|---|---|---|---|
-| AWS | 584 | 85 | 40 | 17 | Official | UI, API, CLI |
-| GCP | 89 | 17 | 14 | 5 | Official | UI, API, CLI |
-| Azure | 169 | 22 | 15 | 8 | Official | UI, API, CLI |
-| Kubernetes | 84 | 7 | 6 | 9 | Official | UI, API, CLI |
+| AWS | 584 | 84 | 40 | 17 | Official | UI, API, CLI |
+| Azure | 169 | 22 | 16 | 12 | Official | UI, API, CLI |
+| GCP | 100 | 17 | 14 | 7 | Official | UI, API, CLI |
+| Kubernetes | 84 | 7 | 7 | 9 | Official | UI, API, CLI |
| GitHub | 20 | 2 | 1 | 2 | Official | UI, API, CLI |
-| M365 | 70 | 7 | 3 | 2 | Official | UI, API, CLI |
-| OCI | 52 | 15 | 1 | 12 | Official | UI, API, CLI |
-| Alibaba Cloud | 63 | 10 | 1 | 9 | Official | CLI |
+| M365 | 71 | 7 | 4 | 3 | Official | UI, API, CLI |
+| OCI | 52 | 14 | 1 | 12 | Official | UI, API, CLI |
+| Alibaba Cloud | 64 | 9 | 2 | 9 | Official | UI, API, CLI |
+| Cloudflare | 23 | 2 | 0 | 5 | Official | CLI |
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
-| MongoDB Atlas | 10 | 4 | 0 | 3 | Official | UI, API, CLI |
+| MongoDB Atlas | 10 | 3 | 0 | 3 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
diff --git a/api/AGENTS.md b/api/AGENTS.md
index f43e0dcbb3..399f12b691 100644
--- a/api/AGENTS.md
+++ b/api/AGENTS.md
@@ -4,6 +4,7 @@
> - [`prowler-api`](../skills/prowler-api/SKILL.md) - Models, Serializers, Views, RLS patterns
> - [`prowler-test-api`](../skills/prowler-test-api/SKILL.md) - Testing patterns (pytest-django)
> - [`django-drf`](../skills/django-drf/SKILL.md) - Generic DRF patterns
+> - [`jsonapi`](../skills/jsonapi/SKILL.md) - Strict JSON:API v1.1 spec compliance
> - [`pytest`](../skills/pytest/SKILL.md) - Generic pytest patterns
### Auto-invoke Skills
@@ -12,9 +13,20 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
|--------|-------|
+| Add changelog entry for a PR or feature | `prowler-changelog` |
+| Adding DRF pagination or permissions | `django-drf` |
+| Committing changes | `prowler-commit` |
+| Create PR that requires changelog entry | `prowler-changelog` |
+| Creating API endpoints | `jsonapi` |
+| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
+| Creating a git commit | `prowler-commit` |
| Creating/modifying models, views, serializers | `prowler-api` |
-| Generic DRF patterns | `django-drf` |
+| Implementing JSON:API endpoints | `django-drf` |
+| Modifying API responses | `jsonapi` |
+| Review changelog format and conventions | `prowler-changelog` |
+| Reviewing JSON:API compliance | `jsonapi` |
| Testing RLS tenant isolation | `prowler-test-api` |
+| Update CHANGELOG.md in any component | `prowler-changelog` |
| Writing Prowler API tests | `prowler-test-api` |
| Writing Python tests with pytest | `pytest` |
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 48a7753832..34a38ada6f 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,45 +2,96 @@
All notable changes to the **Prowler API** are documented in this file.
-## [1.18.0] (Prowler UNRELEASED)
+## [1.19.0] (Prowler UNRELEASED)
-### Added
-- `/api/v1/overviews/compliance-watchlist` to retrieve the compliance watchlist [(#9596)](https://github.com/prowler-cloud/prowler/pull/9596)
-- Support AlibabaCloud provider [(#9485)](https://github.com/prowler-cloud/prowler/pull/9485)
-- `provider_id` and `provider_id__in` filter aliases for findings endpoints to enable consistent frontend parameter naming [(#9701)](https://github.com/prowler-cloud/prowler/pull/9701)
+### π Added
+
+- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885)
+- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864)
+- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444)
+- `GET /api/v1/resources/{id}/events` endpoint to retrieve AWS resource modification history from CloudTrail [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
+- Partial index on findings to speed up new failed findings queries [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904)
+
+### π Changed
+
+- Lazy-load providers and compliance data to reduce API/worker startup memory and time [(#9857)](https://github.com/prowler-cloud/prowler/pull/9857)
+- Remove unused indexes [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904)
---
-## [1.17.2] (Prowler v5.16.2)
+## [1.18.2] (Prowler UNRELEASED)
-### Security
-- Updated dependencies to patch security vulnerabilities: Django 5.1.15 (CVE-2025-64460, CVE-2025-13372), Werkzeug 3.1.4 (CVE-2025-66221), sqlparse 0.5.5 (PVE-2025-82038), fonttools 4.60.2 (CVE-2025-66034) [(#9730)](https://github.com/prowler-cloud/prowler/pull/9730)
+### π Fixed
+
+- Attack Paths: `aws-security-groups-open-internet-facing` query returning no results due to incorrect relationship matching [(#9892)](https://github.com/prowler-cloud/prowler/pull/9892)
+
+---
+
+## [1.18.1] (Prowler v5.17.1)
+
+### π Fixed
+
+- Improve API startup process by `manage.py` argument detection [(#9856)](https://github.com/prowler-cloud/prowler/pull/9856)
+- Deleting providers don't try to delete a `None` Neo4j database when an Attack Paths scan is scheduled [(#9858)](https://github.com/prowler-cloud/prowler/pull/9858)
+- Use replica database for reading Findings to add them to the Attack Paths graph [(#9861)](https://github.com/prowler-cloud/prowler/pull/9861)
+- Attack paths findings loading query to use streaming generator for O(batch_size) memory instead of O(total_findings) [(#9862)](https://github.com/prowler-cloud/prowler/pull/9862)
+- Lazy load Neo4j driver [(#9868)](https://github.com/prowler-cloud/prowler/pull/9868)
+- Use `Findings.all_objects` to avoid the `ActiveProviderPartitionedManager` [(#9869)](https://github.com/prowler-cloud/prowler/pull/9869)
+- Lazy load Neo4j driver for workers only [(#9872)](https://github.com/prowler-cloud/prowler/pull/9872)
+- Improve Cypher query for inserting Findings into Attack Paths scan graphs [(#9874)](https://github.com/prowler-cloud/prowler/pull/9874)
+- Clear Neo4j database cache after Attack Paths scan and each API query [(#9877)](https://github.com/prowler-cloud/prowler/pull/9877)
+- Deduplicated scheduled scans for long-running providers [(#9829)](https://github.com/prowler-cloud/prowler/pull/9829)
+
+---
+
+## [1.18.0] (Prowler v5.17.0)
+
+### π Added
+
+- `/api/v1/overviews/compliance-watchlist` endpoint to retrieve the compliance watchlist [(#9596)](https://github.com/prowler-cloud/prowler/pull/9596)
+- AlibabaCloud provider support [(#9485)](https://github.com/prowler-cloud/prowler/pull/9485)
+- `/api/v1/overviews/resource-groups` endpoint to retrieve an overview of resource groups based on finding severities [(#9694)](https://github.com/prowler-cloud/prowler/pull/9694)
+- `group` filter for `GET /findings` and `GET /findings/metadata/latest` endpoints [(#9694)](https://github.com/prowler-cloud/prowler/pull/9694)
+- `provider_id` and `provider_id__in` filter aliases for findings endpoints to enable consistent frontend parameter naming [(#9701)](https://github.com/prowler-cloud/prowler/pull/9701)
+- Attack Paths: `/api/v1/attack-paths-scans` for AWS providers backed by Neo4j [(#9805)](https://github.com/prowler-cloud/prowler/pull/9805)
+
+### π Security
+
+- Django 5.1.15 (CVE-2025-64460, CVE-2025-13372), Werkzeug 3.1.4 (CVE-2025-66221), sqlparse 0.5.5 (PVE-2025-82038), fonttools 4.60.2 (CVE-2025-66034) [(#9730)](https://github.com/prowler-cloud/prowler/pull/9730)
+- `safety` to `3.7.0` and `filelock` to `3.20.3` due to [Safety vulnerability 82754 (CVE-2025-68146)](https://data.safetycli.com/v/82754/97c/) [(#9816)](https://github.com/prowler-cloud/prowler/pull/9816)
+- `pyasn1` to v0.6.2 to address [CVE-2026-23490](https://nvd.nist.gov/vuln/detail/CVE-2026-23490) [(#9818)](https://github.com/prowler-cloud/prowler/pull/9818)
+- `django-allauth[saml]` to v65.13.0 to address [CVE-2025-65431](https://nvd.nist.gov/vuln/detail/CVE-2025-65431) [(#9575)](https://github.com/prowler-cloud/prowler/pull/9575)
---
## [1.17.1] (Prowler v5.16.1)
-### Changed
+### π Changed
+
- Security Hub integration error when no regions [(#9635)](https://github.com/prowler-cloud/prowler/pull/9635)
-### Fixed
+### π Fixed
+
- Orphan scheduled scans caused by transaction isolation during provider creation [(#9633)](https://github.com/prowler-cloud/prowler/pull/9633)
---
## [1.17.0] (Prowler v5.16.0)
-### Added
+### π Added
+
- New endpoint to retrieve and overview of the categories based on finding severities [(#9529)](https://github.com/prowler-cloud/prowler/pull/9529)
- Endpoints `GET /findings` and `GET /findings/latests` can now use the category filter [(#9529)](https://github.com/prowler-cloud/prowler/pull/9529)
- Account id, alias and provider name to PDF reporting table [(#9574)](https://github.com/prowler-cloud/prowler/pull/9574)
-### Changed
+### π Changed
+
- Endpoint `GET /overviews/attack-surfaces` no longer returns the related check IDs [(#9529)](https://github.com/prowler-cloud/prowler/pull/9529)
- OpenAI provider to only load chat-compatible models with tool calling support [(#9523)](https://github.com/prowler-cloud/prowler/pull/9523)
- Increased execution delay for the first scheduled scan tasks to 5 seconds[(#9558)](https://github.com/prowler-cloud/prowler/pull/9558)
-### Fixed
+### π Fixed
+
- Made `scan_id` a required filter in the compliance overview endpoint [(#9560)](https://github.com/prowler-cloud/prowler/pull/9560)
- Reduced unnecessary UPDATE resources operations by only saving when tag mappings change, lowering write load during scans [(#9569)](https://github.com/prowler-cloud/prowler/pull/9569)
@@ -48,19 +99,22 @@ All notable changes to the **Prowler API** are documented in this file.
## [1.16.1] (Prowler v5.15.1)
-### Fixed
+### π Fixed
+
- Race condition in scheduled scan creation by adding countdown to task [(#9516)](https://github.com/prowler-cloud/prowler/pull/9516)
## [1.16.0] (Prowler v5.15.0)
-### Added
+### π Added
+
- New endpoint to retrieve an overview of the attack surfaces [(#9309)](https://github.com/prowler-cloud/prowler/pull/9309)
- New endpoint `GET /api/v1/overviews/findings_severity/timeseries` to retrieve daily aggregated findings by severity level [(#9363)](https://github.com/prowler-cloud/prowler/pull/9363)
- Lighthouse AI support for Amazon Bedrock API key [(#9343)](https://github.com/prowler-cloud/prowler/pull/9343)
- Exception handler for provider deletions during scans [(#9414)](https://github.com/prowler-cloud/prowler/pull/9414)
- Support to use admin credentials through the read replica database [(#9440)](https://github.com/prowler-cloud/prowler/pull/9440)
-### Changed
+### π Changed
+
- Error messages from Lighthouse celery tasks [(#9165)](https://github.com/prowler-cloud/prowler/pull/9165)
- Restore the compliance overview endpoint's mandatory filters [(#9338)](https://github.com/prowler-cloud/prowler/pull/9338)
@@ -68,7 +122,8 @@ All notable changes to the **Prowler API** are documented in this file.
## [1.15.2] (Prowler v5.14.2)
-### Fixed
+### π Fixed
+
- Unique constraint violation during compliance overviews task [(#9436)](https://github.com/prowler-cloud/prowler/pull/9436)
- Division by zero error in ENS PDF report when all requirements are manual [(#9443)](https://github.com/prowler-cloud/prowler/pull/9443)
@@ -76,7 +131,8 @@ All notable changes to the **Prowler API** are documented in this file.
## [1.15.1] (Prowler v5.14.1)
-### Fixed
+### π Fixed
+
- Fix typo in PDF reporting [(#9345)](https://github.com/prowler-cloud/prowler/pull/9345)
- Fix IaC provider initialization failure when mutelist processor is configured [(#9331)](https://github.com/prowler-cloud/prowler/pull/9331)
- Match logic for ThreatScore when counting findings [(#9348)](https://github.com/prowler-cloud/prowler/pull/9348)
@@ -85,7 +141,8 @@ All notable changes to the **Prowler API** are documented in this file.
## [1.15.0] (Prowler v5.14.0)
-### Added
+### π Added
+
- IaC (Infrastructure as Code) provider support for remote repositories [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751)
- Extend `GET /api/v1/providers` with provider-type filters and optional pagination disable to support the new Overview filters [(#8975)](https://github.com/prowler-cloud/prowler/pull/8975)
- New endpoint to retrieve the number of providers grouped by provider type [(#8975)](https://github.com/prowler-cloud/prowler/pull/8975)
@@ -104,11 +161,13 @@ All notable changes to the **Prowler API** are documented in this file.
- Enhanced compliance overview endpoint with provider filtering and latest scan aggregation [(#9244)](https://github.com/prowler-cloud/prowler/pull/9244)
- New endpoint `GET /api/v1/overview/regions` to retrieve aggregated findings data by region [(#9273)](https://github.com/prowler-cloud/prowler/pull/9273)
-### Changed
+### π Changed
+
- Optimized database write queries for scan related tasks [(#9190)](https://github.com/prowler-cloud/prowler/pull/9190)
- Date filters are now optional for `GET /api/v1/overviews/services` endpoint; returns latest scan data by default [(#9248)](https://github.com/prowler-cloud/prowler/pull/9248)
-### Fixed
+### π Fixed
+
- Scans no longer fail when findings have UIDs exceeding 300 characters; such findings are now skipped with detailed logging [(#9246)](https://github.com/prowler-cloud/prowler/pull/9246)
- Updated unique constraint for `Provider` model to exclude soft-deleted entries, resolving duplicate errors when re-deleting providers [(#9054)](https://github.com/prowler-cloud/prowler/pull/9054)
- Removed compliance generation for providers without compliance frameworks [(#9208)](https://github.com/prowler-cloud/prowler/pull/9208)
@@ -116,14 +175,16 @@ All notable changes to the **Prowler API** are documented in this file.
- Severity overview endpoint now ignores muted findings as expected [(#9283)](https://github.com/prowler-cloud/prowler/pull/9283)
- Fixed discrepancy between ThreatScore PDF report values and database calculations [(#9296)](https://github.com/prowler-cloud/prowler/pull/9296)
-### Security
+### π Security
+
- Django updated to the latest 5.1 security release, 5.1.14, due to problems with potential [SQL injection](https://github.com/prowler-cloud/prowler/security/dependabot/113) and [denial-of-service vulnerability](https://github.com/prowler-cloud/prowler/security/dependabot/114) [(#9176)](https://github.com/prowler-cloud/prowler/pull/9176)
---
## [1.14.1] (Prowler v5.13.1)
-### Fixed
+### π Fixed
+
- `/api/v1/overviews/providers` collapses data by provider type so the UI receives a single aggregated record per cloud family even when multiple accounts exist [(#9053)](https://github.com/prowler-cloud/prowler/pull/9053)
- Added retry logic to database transactions to handle Aurora read replica connection failures during scale-down events [(#9064)](https://github.com/prowler-cloud/prowler/pull/9064)
- Security Hub integrations stop failing when they read relationships via the replica by allowing replica relations and saving updates through the primary [(#9080)](https://github.com/prowler-cloud/prowler/pull/9080)
@@ -132,7 +193,8 @@ All notable changes to the **Prowler API** are documented in this file.
## [1.14.0] (Prowler v5.13.0)
-### Added
+### π Added
+
- Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655)
- `compliance_name` for each compliance [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920)
- Support C5 compliance framework for the AWS provider [(#8830)](https://github.com/prowler-cloud/prowler/pull/8830)
@@ -145,35 +207,41 @@ All notable changes to the **Prowler API** are documented in this file.
- Support Common Cloud Controls for AWS, Azure and GCP [(#8000)](https://github.com/prowler-cloud/prowler/pull/8000)
- Add `provider_id__in` filter support to findings and findings severity overview endpoints [(#8951)](https://github.com/prowler-cloud/prowler/pull/8951)
-### Changed
+### π Changed
+
- Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281)
- Now at least one user with MANAGE_ACCOUNT permission is required in the tenant [(#8729)](https://github.com/prowler-cloud/prowler/pull/8729)
-### Security
+### π Security
+
- Django updated to the latest 5.1 security release, 5.1.13, due to problems with potential [SQL injection](https://github.com/prowler-cloud/prowler/security/dependabot/104) and [directory traversals](https://github.com/prowler-cloud/prowler/security/dependabot/103) [(#8842)](https://github.com/prowler-cloud/prowler/pull/8842)
---
## [1.13.2] (Prowler v5.12.3)
-### Fixed
+### π Fixed
+
- 500 error when deleting user [(#8731)](https://github.com/prowler-cloud/prowler/pull/8731)
---
## [1.13.1] (Prowler v5.12.2)
-### Changed
+### π Changed
+
- Renamed compliance overview task queue to `compliance` [(#8755)](https://github.com/prowler-cloud/prowler/pull/8755)
-### Security
+### π Security
+
- Django updated to the latest 5.1 security release, 5.1.12, due to [problems](https://www.djangoproject.com/weblog/2025/sep/03/security-releases/) with potential SQL injection in FilteredRelation column aliases [(#8693)](https://github.com/prowler-cloud/prowler/pull/8693)
---
## [1.13.0] (Prowler v5.12.0)
-### Added
+### π Added
+
- Integration with JIRA, enabling sending findings to a JIRA project [(#8622)](https://github.com/prowler-cloud/prowler/pull/8622), [(#8637)](https://github.com/prowler-cloud/prowler/pull/8637)
- `GET /overviews/findings_severity` now supports `filter[status]` and `filter[status__in]` to aggregate by specific statuses (`FAIL`, `PASS`)[(#8186)](https://github.com/prowler-cloud/prowler/pull/8186)
- Throttling options for `/api/v1/tokens` using the `DJANGO_THROTTLE_TOKEN_OBTAIN` environment variable [(#8647)](https://github.com/prowler-cloud/prowler/pull/8647)
@@ -182,101 +250,120 @@ All notable changes to the **Prowler API** are documented in this file.
## [1.12.0] (Prowler v5.11.0)
-### Added
+### π Added
+
- Lighthouse support for OpenAI GPT-5 [(#8527)](https://github.com/prowler-cloud/prowler/pull/8527)
- Integration with Amazon Security Hub, enabling sending findings to Security Hub [(#8365)](https://github.com/prowler-cloud/prowler/pull/8365)
- Generate ASFF output for AWS providers with SecurityHub integration enabled [(#8569)](https://github.com/prowler-cloud/prowler/pull/8569)
-### Fixed
+### π Fixed
+
- GitHub provider always scans user instead of organization when using provider UID [(#8587)](https://github.com/prowler-cloud/prowler/pull/8587)
---
## [1.11.0] (Prowler v5.10.0)
-### Added
+### π Added
+
- Github provider support [(#8271)](https://github.com/prowler-cloud/prowler/pull/8271)
- Integration with Amazon S3, enabling storage and retrieval of scan data via S3 buckets [(#8056)](https://github.com/prowler-cloud/prowler/pull/8056)
-### Fixed
+### π Fixed
+
- Avoid sending errors to Sentry in M365 provider when user authentication fails [(#8420)](https://github.com/prowler-cloud/prowler/pull/8420)
---
## [1.10.2] (Prowler v5.9.2)
-### Changed
+### π Changed
+
- Optimized queries for resources views [(#8336)](https://github.com/prowler-cloud/prowler/pull/8336)
---
## [v1.10.1] (Prowler v5.9.1)
-### Fixed
+### π Fixed
+
- Calculate failed findings during scans to prevent heavy database queries [(#8322)](https://github.com/prowler-cloud/prowler/pull/8322)
---
## [v1.10.0] (Prowler v5.9.0)
-### Added
+### π Added
+
- SSO with SAML support [(#8175)](https://github.com/prowler-cloud/prowler/pull/8175)
- `GET /resources/metadata`, `GET /resources/metadata/latest` and `GET /resources/latest` to expose resource metadata and latest scan results [(#8112)](https://github.com/prowler-cloud/prowler/pull/8112)
-### Changed
+### π Changed
+
- `/processors` endpoints to post-process findings. Currently, only the Mutelist processor is supported to allow to mute findings.
- Optimized the underlying queries for resources endpoints [(#8112)](https://github.com/prowler-cloud/prowler/pull/8112)
- Optimized include parameters for resources view [(#8229)](https://github.com/prowler-cloud/prowler/pull/8229)
- Optimized overview background tasks [(#8300)](https://github.com/prowler-cloud/prowler/pull/8300)
-### Fixed
+### π Fixed
+
- Search filter for findings and resources [(#8112)](https://github.com/prowler-cloud/prowler/pull/8112)
- RBAC is now applied to `GET /overviews/providers` [(#8277)](https://github.com/prowler-cloud/prowler/pull/8277)
-### Changed
+### π Changed
+
- `POST /schedules/daily` returns a `409 CONFLICT` if already created [(#8258)](https://github.com/prowler-cloud/prowler/pull/8258)
-### Security
+### π Security
+
- Enhanced password validation to enforce 12+ character passwords with special characters, uppercase, lowercase, and numbers [(#8225)](https://github.com/prowler-cloud/prowler/pull/8225)
---
## [v1.9.1] (Prowler v5.8.1)
-### Added
+### π Added
+
- Custom exception for provider connection errors during scans [(#8234)](https://github.com/prowler-cloud/prowler/pull/8234)
-### Changed
+### π Changed
+
- Summary and overview tasks now use a dedicated queue and no longer propagate errors to compliance tasks [(#8214)](https://github.com/prowler-cloud/prowler/pull/8214)
-### Fixed
+### π Fixed
+
- Scan with no resources will not trigger legacy code for findings metadata [(#8183)](https://github.com/prowler-cloud/prowler/pull/8183)
- Invitation email comparison case-insensitive [(#8206)](https://github.com/prowler-cloud/prowler/pull/8206)
-### Removed
+### β Removed
+
- Validation of the provider's secret type during updates [(#8197)](https://github.com/prowler-cloud/prowler/pull/8197)
---
## [v1.9.0] (Prowler v5.8.0)
-### Added
+### π Added
+
- Support GCP Service Account key [(#7824)](https://github.com/prowler-cloud/prowler/pull/7824)
- `GET /compliance-overviews` endpoints to retrieve compliance metadata and specific requirements statuses [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877)
- Lighthouse configuration support [(#7848)](https://github.com/prowler-cloud/prowler/pull/7848)
-### Changed
+### π Changed
+
- Reworked `GET /compliance-overviews` to return proper requirement metrics [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877)
- Optional `user` and `password` for M365 provider [(#7992)](https://github.com/prowler-cloud/prowler/pull/7992)
-### Fixed
+### π Fixed
+
- Scheduled scans are no longer deleted when their daily schedule run is disabled [(#8082)](https://github.com/prowler-cloud/prowler/pull/8082)
---
## [v1.8.5] (Prowler v5.7.5)
-### Fixed
+### π Fixed
+
- Normalize provider UID to ensure safe and unique export directory paths [(#8007)](https://github.com/prowler-cloud/prowler/pull/8007).
- Blank resource types in `/metadata` endpoints [(#8027)](https://github.com/prowler-cloud/prowler/pull/8027)
@@ -284,20 +371,24 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.8.4] (Prowler v5.7.4)
-### Removed
+### β Removed
+
- Reverted RLS transaction handling and DB custom backend [(#7994)](https://github.com/prowler-cloud/prowler/pull/7994)
---
## [v1.8.3] (Prowler v5.7.3)
-### Added
+### π Added
+
- Database backend to handle already closed connections [(#7935)](https://github.com/prowler-cloud/prowler/pull/7935)
-### Changed
+### π Changed
+
- Renamed field encrypted_password to password for M365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784)
-### Fixed
+### π Fixed
+
- Transaction persistence with RLS operations [(#7916)](https://github.com/prowler-cloud/prowler/pull/7916)
- Reverted the change `get_with_retry` to use the original `get` method for retrieving tasks [(#7932)](https://github.com/prowler-cloud/prowler/pull/7932)
@@ -305,7 +396,8 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.8.2] (Prowler v5.7.2)
-### Fixed
+### π Fixed
+
- Task lookup to use task_kwargs instead of task_args for scan report resolution [(#7830)](https://github.com/prowler-cloud/prowler/pull/7830)
- Kubernetes UID validation to allow valid context names [(#7871)](https://github.com/prowler-cloud/prowler/pull/7871)
- Connection status verification before launching a scan [(#7831)](https://github.com/prowler-cloud/prowler/pull/7831)
@@ -316,14 +408,16 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.8.1] (Prowler v5.7.1)
-### Fixed
+### π Fixed
+
- Added database index to improve performance on finding lookup [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800)
---
## [v1.8.0] (Prowler v5.7.0)
-### Added
+### π Added
+
- Huge improvements to `/findings/metadata` and resource related filters for findings [(#7690)](https://github.com/prowler-cloud/prowler/pull/7690)
- Improvements to `/overviews` endpoints [(#7690)](https://github.com/prowler-cloud/prowler/pull/7690)
- Queue to perform backfill background tasks [(#7690)](https://github.com/prowler-cloud/prowler/pull/7690)
@@ -334,7 +428,7 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.7.0] (Prowler v5.6.0)
-### Added
+### π Added
- M365 as a new provider [(#7563)](https://github.com/prowler-cloud/prowler/pull/7563)
- `compliance/` folder and ZIPβexport functionality for all compliance reports [(#7653)](https://github.com/prowler-cloud/prowler/pull/7653)
@@ -344,7 +438,7 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.6.0] (Prowler v5.5.0)
-### Added
+### π Added
- Support for developing new integrations [(#7167)](https://github.com/prowler-cloud/prowler/pull/7167)
- HTTP Security Headers [(#7289)](https://github.com/prowler-cloud/prowler/pull/7289)
@@ -356,14 +450,16 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.5.4] (Prowler v5.4.4)
-### Fixed
+### π Fixed
+
- Bug with periodic tasks when trying to delete a provider [(#7466)](https://github.com/prowler-cloud/prowler/pull/7466)
---
## [v1.5.3] (Prowler v5.4.3)
-### Fixed
+### π Fixed
+
- Duplicated scheduled scans handling [(#7401)](https://github.com/prowler-cloud/prowler/pull/7401)
- Environment variable to configure the deletion task batch size [(#7423)](https://github.com/prowler-cloud/prowler/pull/7423)
@@ -371,14 +467,16 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.5.2] (Prowler v5.4.2)
-### Changed
+### π Changed
+
- Refactored deletion logic and implemented retry mechanism for deletion tasks [(#7349)](https://github.com/prowler-cloud/prowler/pull/7349)
---
## [v1.5.1] (Prowler v5.4.1)
-### Fixed
+### π Fixed
+
- Handle response in case local files are missing [(#7183)](https://github.com/prowler-cloud/prowler/pull/7183)
- Race condition when deleting export files after the S3 upload [(#7172)](https://github.com/prowler-cloud/prowler/pull/7172)
- Handle exception when a provider has no secret in test connection [(#7283)](https://github.com/prowler-cloud/prowler/pull/7283)
@@ -387,19 +485,22 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.5.0] (Prowler v5.4.0)
-### Added
+### π Added
+
- Social login integration with Google and GitHub [(#6906)](https://github.com/prowler-cloud/prowler/pull/6906)
- API scan report system, now all scans launched from the API will generate a compressed file with the report in OCSF, CSV and HTML formats [(#6878)](https://github.com/prowler-cloud/prowler/pull/6878)
- Configurable Sentry integration [(#6874)](https://github.com/prowler-cloud/prowler/pull/6874)
-### Changed
+### π Changed
+
- Optimized `GET /findings` endpoint to improve response time and size [(#7019)](https://github.com/prowler-cloud/prowler/pull/7019)
---
## [v1.4.0] (Prowler v5.3.0)
-### Changed
+### π Changed
+
- Daily scheduled scan instances are now created beforehand with `SCHEDULED` state [(#6700)](https://github.com/prowler-cloud/prowler/pull/6700)
- Findings endpoints now require at least one date filter [(#6800)](https://github.com/prowler-cloud/prowler/pull/6800)
- Findings metadata endpoint received a performance improvement [(#6863)](https://github.com/prowler-cloud/prowler/pull/6863)
diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh
index f9b19e04d0..eea024a4a2 100755
--- a/api/docker-entrypoint.sh
+++ b/api/docker-entrypoint.sh
@@ -32,7 +32,7 @@ start_prod_server() {
start_worker() {
echo "Starting the worker..."
- poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance -E --max-tasks-per-child 1
+ poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans -E --max-tasks-per-child 1
}
start_worker_beat() {
diff --git a/api/poetry.lock b/api/poetry.lock
index 84e67cd040..3ee0f5f918 100644
--- a/api/poetry.lock
+++ b/api/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -12,6 +12,71 @@ files = [
{file = "about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341"},
]
+[[package]]
+name = "adal"
+version = "1.2.7"
+description = "Note: This library is already replaced by MSAL Python, available here: https://pypi.org/project/msal/ .ADAL Python remains available here as a legacy. The ADAL for Python library makes it easy for python application to authenticate to Azure Active Directory (AAD) in order to access AAD protected web resources."
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "adal-1.2.7-py2.py3-none-any.whl", hash = "sha256:2a7451ed7441ddbc57703042204a3e30ef747478eea022c70f789fc7f084bc3d"},
+ {file = "adal-1.2.7.tar.gz", hash = "sha256:d74f45b81317454d96e982fd1c50e6fb5c99ac2223728aea8764433a39f566f1"},
+]
+
+[package.dependencies]
+cryptography = ">=1.1.0"
+PyJWT = ">=1.0.0,<3"
+python-dateutil = ">=2.1.0,<3"
+requests = ">=2.0.0,<3"
+
+[[package]]
+name = "aioboto3"
+version = "15.5.0"
+description = "Async boto3 wrapper"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6"},
+ {file = "aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979"},
+]
+
+[package.dependencies]
+aiobotocore = {version = "2.25.1", extras = ["boto3"]}
+aiofiles = ">=23.2.1"
+
+[package.extras]
+chalice = ["chalice (>=1.24.0)"]
+s3cse = ["cryptography (>=44.0.1)"]
+
+[[package]]
+name = "aiobotocore"
+version = "2.25.1"
+description = "Async client for aws services using botocore and aiohttp"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f"},
+ {file = "aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc"},
+]
+
+[package.dependencies]
+aiohttp = ">=3.9.2,<4.0.0"
+aioitertools = ">=0.5.1,<1.0.0"
+boto3 = {version = ">=1.40.46,<1.40.62", optional = true, markers = "extra == \"boto3\""}
+botocore = ">=1.40.46,<1.40.62"
+jmespath = ">=0.7.1,<2.0.0"
+multidict = ">=6.0.0,<7.0.0"
+python-dateutil = ">=2.1,<3.0.0"
+wrapt = ">=1.10.10,<2.0.0"
+
+[package.extras]
+awscli = ["awscli (>=1.42.46,<1.42.62)"]
+boto3 = ["boto3 (>=1.40.46,<1.40.62)"]
+httpx = ["httpx (>=0.25.1,<0.29)"]
+
[[package]]
name = "aiofiles"
version = "24.1.0"
@@ -178,6 +243,18 @@ yarl = ">=1.17.0,<2.0"
[package.extras]
speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""]
+[[package]]
+name = "aioitertools"
+version = "0.13.0"
+description = "itertools and builtins for AsyncIO and mixed iterables"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be"},
+ {file = "aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c"},
+]
+
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -451,17 +528,18 @@ alibabacloud_credentials = ">=0.3.4"
[[package]]
name = "alibabacloud-openapi-util"
-version = "0.2.2"
+version = "0.2.4"
description = "Aliyun Tea OpenApi Library for Python"
optional = false
python-versions = "*"
groups = ["main"]
files = [
- {file = "alibabacloud_openapi_util-0.2.2.tar.gz", hash = "sha256:ebbc3906f554cb4bf8f513e43e8a33e8b6a3d4a0ef13617a0e14c3dda8ef52a8"},
+ {file = "alibabacloud_openapi_util-0.2.4-py3-none-any.whl", hash = "sha256:a2474f230b5965ae9a8c286e0dc86132a887928d02d20b8182656cf6b1b6c5bd"},
+ {file = "alibabacloud_openapi_util-0.2.4.tar.gz", hash = "sha256:87022b9dcb7593a601f7a40ca698227ac3ccb776b58cb7b06b8dc7f510995c34"},
]
[package.dependencies]
-alibabacloud_tea_util = ">=0.0.2"
+alibabacloud-tea-util = ">=0.3.13,<1.0.0"
cryptography = ">=3.0.0"
[[package]]
@@ -743,7 +821,7 @@ version = "4.10.0"
description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false
python-versions = ">=3.9"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"},
{file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"},
@@ -757,6 +835,18 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
trio = ["trio (>=0.26.1)"]
+[[package]]
+name = "applicationinsights"
+version = "0.11.10"
+description = "This project extends the Application Insights API surface to support Python."
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "applicationinsights-0.11.10-py2.py3-none-any.whl", hash = "sha256:e89a890db1c6906b6a7d0bcfd617dac83974773c64573147c8d6654f9cf2a6ea"},
+ {file = "applicationinsights-0.11.10.tar.gz", hash = "sha256:0b761f3ef0680acf4731906dfc1807faa6f2a57168ae74592db0084a6099f7b3"},
+]
+
[[package]]
name = "apscheduler"
version = "3.11.2"
@@ -785,6 +875,21 @@ tornado = ["tornado (>=4.3)"]
twisted = ["twisted"]
zookeeper = ["kazoo"]
+[[package]]
+name = "argcomplete"
+version = "3.5.3"
+description = "Bash tab completion for argparse"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "argcomplete-3.5.3-py3-none-any.whl", hash = "sha256:2ab2c4a215c59fd6caaff41a869480a23e8f6a5f910b266c1808037f4e375b61"},
+ {file = "argcomplete-3.5.3.tar.gz", hash = "sha256:c12bf50eded8aebb298c7b7da7a5ff3ee24dffd9f5281867dfe1424b58c55392"},
+]
+
+[package.extras]
+test = ["coverage", "mypy", "pexpect", "ruff", "wheel"]
+
[[package]]
name = "asgiref"
version = "3.9.1"
@@ -887,6 +992,58 @@ files = [
{file = "awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0"},
]
+[[package]]
+name = "azure-cli-core"
+version = "2.81.0"
+description = "Microsoft Azure Command-Line Tools Core Module"
+optional = false
+python-versions = ">=3.10.0"
+groups = ["main"]
+files = [
+ {file = "azure_cli_core-2.81.0-py3-none-any.whl", hash = "sha256:2c711ff890d003b10bb40f932d008a872c6451324ee533fbbb861c950c3f0f93"},
+ {file = "azure_cli_core-2.81.0.tar.gz", hash = "sha256:bae30dcd6f8c1883c8e32fbcb7c43dbe2dbfe522e428c7629edef271933f7b1e"},
+]
+
+[package.dependencies]
+argcomplete = ">=3.5.2,<3.6.0"
+azure-cli-telemetry = "==1.1.0.*"
+azure-core = ">=1.35.0,<1.36.0"
+azure-mgmt-core = ">=1.2.0,<2"
+cryptography = "*"
+distro = {version = "*", markers = "sys_platform == \"linux\""}
+humanfriendly = ">=10.0,<11.0"
+jmespath = "*"
+knack = ">=0.11.0,<0.12.0"
+microsoft-security-utilities-secret-masker = ">=1.0.0b4,<1.1.0"
+msal = [
+ {version = "1.34.0b1", extras = ["broker"], markers = "sys_platform == \"win32\""},
+ {version = "1.34.0b1", markers = "sys_platform != \"win32\""},
+]
+msal-extensions = "1.2.0"
+packaging = ">=20.9"
+pkginfo = ">=1.5.0.1"
+psutil = {version = ">=5.9", markers = "sys_platform != \"cygwin\""}
+py-deviceid = "*"
+PyJWT = ">=2.1.0"
+pyopenssl = ">=17.1.0"
+requests = {version = "*", extras = ["socks"]}
+
+[[package]]
+name = "azure-cli-telemetry"
+version = "1.1.0"
+description = "Microsoft Azure CLI Telemetry Package"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "azure-cli-telemetry-1.1.0.tar.gz", hash = "sha256:d922379cda1b48952be75fb3bd2ac5e7ceecf569492a6088bab77894c624a278"},
+ {file = "azure_cli_telemetry-1.1.0-py3-none-any.whl", hash = "sha256:2fc12608c0cf0ea6e69b392af9cab92f1249340b8caff7e9674cf91b3becb337"},
+]
+
+[package.dependencies]
+applicationinsights = ">=0.11.1,<0.12"
+portalocker = ">=1.6,<3"
+
[[package]]
name = "azure-common"
version = "1.1.28"
@@ -1027,6 +1184,23 @@ azure-mgmt-core = ">=1.3.2"
isodate = ">=0.6.1"
typing-extensions = ">=4.6.0"
+[[package]]
+name = "azure-mgmt-containerinstance"
+version = "10.1.0"
+description = "Microsoft Azure Container Instance Client Library for Python"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "azure-mgmt-containerinstance-10.1.0.zip", hash = "sha256:78d437adb28574f448c838ed5f01f9ced378196098061deb59d9f7031704c17e"},
+ {file = "azure_mgmt_containerinstance-10.1.0-py3-none-any.whl", hash = "sha256:ee7977b7b70f2233e44ec6ce8c99027f3f7892bb3452b4bad46df340d9f98959"},
+]
+
+[package.dependencies]
+azure-common = ">=1.1,<2.0"
+azure-mgmt-core = ">=1.3.2,<2.0.0"
+isodate = ">=0.6.1,<1.0.0"
+
[[package]]
name = "azure-mgmt-containerregistry"
version = "12.0.0"
@@ -1113,6 +1287,42 @@ azure-common = ">=1.1,<2.0"
azure-mgmt-core = ">=1.3.2,<2.0.0"
isodate = ">=0.6.1,<1.0.0"
+[[package]]
+name = "azure-mgmt-datafactory"
+version = "9.2.0"
+description = "Microsoft Azure Data Factory Management Client Library for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "azure_mgmt_datafactory-9.2.0-py3-none-any.whl", hash = "sha256:d870a7a6099227e91d1c258a956c2aa32c2ea4c0a4409913d8f215887349f128"},
+ {file = "azure_mgmt_datafactory-9.2.0.tar.gz", hash = "sha256:5132e9c24c441ac225f2a60225924baa55079ca81eff7db99a70d661d64bb0d7"},
+]
+
+[package.dependencies]
+azure-common = ">=1.1"
+azure-mgmt-core = ">=1.3.2"
+isodate = ">=0.6.1"
+typing-extensions = ">=4.6.0"
+
+[[package]]
+name = "azure-mgmt-eventgrid"
+version = "10.4.0"
+description = "Microsoft Azure Event Grid Management Client Library for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "azure_mgmt_eventgrid-10.4.0-py3-none-any.whl", hash = "sha256:5e4637245bbff33298d5f427971b870dbb03d873a3ef68f328190a7b7a38c56f"},
+ {file = "azure_mgmt_eventgrid-10.4.0.tar.gz", hash = "sha256:303e5e27cf4bb5ec833ba4e5a9ef70b5bc410e190412ec47cde59d82e413fb7e"},
+]
+
+[package.dependencies]
+azure-common = ">=1.1"
+azure-mgmt-core = ">=1.3.2"
+isodate = ">=0.6.1"
+typing-extensions = ">=4.6.0"
+
[[package]]
name = "azure-mgmt-keyvault"
version = "10.3.1"
@@ -1148,6 +1358,23 @@ azure-common = ">=1.1,<2.0"
azure-mgmt-core = ">=1.2.0,<2.0.0"
msrest = ">=0.6.21"
+[[package]]
+name = "azure-mgmt-logic"
+version = "10.0.0"
+description = "Microsoft Azure Logic Apps Management Client Library for Python"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+ {file = "azure-mgmt-logic-10.0.0.zip", hash = "sha256:b3fa4864f14aaa7af41d778d925f051ed29b6016f46344765ecd0f49d0f04dd6"},
+ {file = "azure_mgmt_logic-10.0.0-py3-none-any.whl", hash = "sha256:525c78afedf3edb35eb0a16152c8beba89769ee1bc6af01bcdc42842a551e443"},
+]
+
+[package.dependencies]
+azure-common = ">=1.1,<2.0"
+azure-mgmt-core = ">=1.3.0,<2.0.0"
+msrest = ">=0.6.21"
+
[[package]]
name = "azure-mgmt-monitor"
version = "6.0.2"
@@ -1414,6 +1641,18 @@ typing-extensions = ">=4.6.0"
[package.extras]
aio = ["azure-core[aio] (>=1.30.0)"]
+[[package]]
+name = "backoff"
+version = "2.2.1"
+description = "Function decoration for backoff and retry"
+optional = false
+python-versions = ">=3.7,<4.0"
+groups = ["main"]
+files = [
+ {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"},
+ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"},
+]
+
[[package]]
name = "bandit"
version = "1.7.9"
@@ -1465,34 +1704,34 @@ files = [
[[package]]
name = "boto3"
-version = "1.39.15"
+version = "1.40.61"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "boto3-1.39.15-py3-none-any.whl", hash = "sha256:38fc54576b925af0075636752de9974e172c8a2cf7133400e3e09b150d20fb6a"},
- {file = "boto3-1.39.15.tar.gz", hash = "sha256:b4483625f0d8c35045254dee46cd3c851bbc0450814f20b9b25bee1b5c0d8409"},
+ {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"},
+ {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"},
]
[package.dependencies]
-botocore = ">=1.39.15,<1.40.0"
+botocore = ">=1.40.61,<1.41.0"
jmespath = ">=0.7.1,<2.0.0"
-s3transfer = ">=0.13.0,<0.14.0"
+s3transfer = ">=0.14.0,<0.15.0"
[package.extras]
crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
-version = "1.39.15"
+version = "1.40.61"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "botocore-1.39.15-py3-none-any.whl", hash = "sha256:eb9cfe918ebfbfb8654e1b153b29f0c129d586d2c0d7fb4032731d49baf04cff"},
- {file = "botocore-1.39.15.tar.gz", hash = "sha256:2aa29a717f14f8c7ca058c2e297aaed0aa10ecea24b91514eee802814d1b7600"},
+ {file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"},
+ {file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"},
]
[package.dependencies]
@@ -1501,7 +1740,7 @@ python-dateutil = ">=2.1,<3.0.0"
urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}
[package.extras]
-crt = ["awscrt (==0.23.8)"]
+crt = ["awscrt (==0.27.6)"]
[[package]]
name = "cachetools"
@@ -1515,6 +1754,75 @@ files = [
{file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"},
]
+[[package]]
+name = "cartography"
+version = "0.0.1.dev1268+gc134846c0"
+description = "Explore assets and their relationships across your technical infrastructure."
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = []
+develop = false
+
+[package.dependencies]
+adal = ">=1.2.4"
+aioboto3 = ">=13.0.0"
+azure-cli-core = ">=2.26.0"
+azure-identity = ">=1.5.0"
+azure-mgmt-authorization = ">=0.60.0"
+azure-mgmt-compute = ">=5.0.0"
+azure-mgmt-containerinstance = ">=10.0.0"
+azure-mgmt-containerservice = ">=30.0.0"
+azure-mgmt-cosmosdb = ">=6.0.0"
+azure-mgmt-datafactory = ">=8.0.0"
+azure-mgmt-eventgrid = ">=10.0.0"
+azure-mgmt-logic = ">=10.0.0"
+azure-mgmt-monitor = ">=3.0.0"
+azure-mgmt-network = ">=25.0.0"
+azure-mgmt-resource = ">=10.2.0"
+azure-mgmt-security = ">=5.0.0"
+azure-mgmt-sql = ">=3.0.1,<4"
+azure-mgmt-storage = ">=16.0.0"
+azure-mgmt-web = ">=7.0.0"
+backoff = ">=2.1.2"
+boto3 = ">=1.15.1"
+botocore = ">=1.18.1"
+cloudflare = ">=4.1.0,<5.0.0"
+crowdstrike-falconpy = ">=0.5.1"
+dnspython = ">=1.15.0"
+duo-client = "*"
+google-api-python-client = ">=1.7.8"
+google-auth = ">=2.37.0"
+google-cloud-asset = ">=1.0.0"
+google-cloud-resource-manager = ">=1.14.2"
+httpx = ">=0.24.0"
+kubernetes = ">=22.6.0"
+marshmallow = ">=3.0.0rc7"
+msgraph-sdk = "*"
+msrestazure = ">=0.6.4"
+neo4j = ">=5.28.2,<6.0.0"
+oci = ">=2.71.0"
+okta = "<1.0.0"
+packaging = "*"
+pdpyras = ">=4.3.0"
+policyuniverse = ">=1.1.0.0"
+python-dateutil = "*"
+python-digitalocean = ">=1.16.0"
+pyyaml = ">=5.3.1"
+requests = ">=2.22.0"
+scaleway = ">=2.10.0"
+slack-sdk = ">=3.37.0"
+statsd = "*"
+typer = ">=0.9.0"
+types-aiobotocore-ecr = "*"
+xmltodict = "*"
+
+[package.source]
+type = "git"
+url = "https://github.com/prowler-cloud/cartography"
+reference = "master"
+resolved_reference = "c134846c0db64747340f880cf0b5085f5e473e03"
+
[[package]]
name = "celery"
version = "5.4.0"
@@ -1851,6 +2159,26 @@ prompt-toolkit = ">=3.0.36"
[package.extras]
testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"]
+[[package]]
+name = "cloudflare"
+version = "4.3.1"
+description = "The official Python library for the cloudflare API"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "cloudflare-4.3.1-py3-none-any.whl", hash = "sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6"},
+ {file = "cloudflare-4.3.1.tar.gz", hash = "sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258"},
+]
+
+[package.dependencies]
+anyio = ">=3.5.0,<5"
+distro = ">=1.7.0,<2"
+httpx = ">=0.23.0,<1"
+pydantic = ">=1.9.0,<3"
+sniffio = "*"
+typing-extensions = ">=4.10,<5"
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -1862,7 +2190,7 @@ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
-markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""}
+markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""}
[[package]]
name = "contextlib2"
@@ -2048,6 +2376,25 @@ files = [
[package.extras]
dev = ["polib"]
+[[package]]
+name = "crowdstrike-falconpy"
+version = "1.5.4"
+description = "The CrowdStrike Falcon SDK for Python"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "crowdstrike_falconpy-1.5.4-py3-none-any.whl", hash = "sha256:f698d4571d0dfac1dc450a69329959e1827d49a92ce9f81d2f9963f6da41b670"},
+ {file = "crowdstrike_falconpy-1.5.4.tar.gz", hash = "sha256:b357850664639af5e8441d1ed5efb905b03af74c7c5f49b4ade161202e8e45c7"},
+]
+
+[package.dependencies]
+requests = "*"
+urllib3 = "*"
+
+[package.extras]
+dev = ["bandit", "coverage", "flake8", "pydocstyle", "pylint", "pytest", "pytest-cov"]
+
[[package]]
name = "cryptography"
version = "44.0.1"
@@ -2328,13 +2675,14 @@ bcrypt = ["bcrypt"]
[[package]]
name = "django-allauth"
-version = "65.11.0"
+version = "65.13.0"
description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "django_allauth-65.11.0.tar.gz", hash = "sha256:d08ee0b60a1a54f84720bb749518628c517c9af40b6cfb3bc980206e182745ab"},
+ {file = "django_allauth-65.13.0-py3-none-any.whl", hash = "sha256:119c0cf1cc2e0d1a0fe2f13588f30951d64989256084de2d60f13ab9308f9fa0"},
+ {file = "django_allauth-65.13.0.tar.gz", hash = "sha256:7d7b7e7ad603eb3864c142f051e2cce7be2f9a9c6945a51172ec83d48c6c843b"},
]
[package.dependencies]
@@ -2346,6 +2694,7 @@ python3-saml = {version = ">=1.15.0,<2.0.0", optional = true, markers = "extra =
requests = {version = ">=2.0.0,<3", optional = true, markers = "extra == \"socialaccount\""}
[package.extras]
+headless = ["pyjwt[crypto] (>=2.0,<3)"]
headless-spec = ["PyYAML (>=6,<7)"]
idp-oidc = ["oauthlib (>=3.3.0,<4)", "pyjwt[crypto] (>=2.0,<3)"]
mfa = ["fido2 (>=1.1.2,<3)", "qrcode (>=7.0.0,<9)"]
@@ -2780,6 +3129,21 @@ merge = ["merge3"]
paramiko = ["paramiko"]
pgp = ["gpg"]
+[[package]]
+name = "duo-client"
+version = "5.5.0"
+description = "Reference client for Duo Security APIs"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "duo_client-5.5.0-py3-none-any.whl", hash = "sha256:4fbf1e97a2b25ef64e9f88171ab817162cf45bafc1c63026af4883baf8892a12"},
+ {file = "duo_client-5.5.0.tar.gz", hash = "sha256:303109e047fe7525ba4fc4a294c1f3deb4125066e89c10d33f7430378867b1d6"},
+]
+
+[package.dependencies]
+setuptools = "*"
+
[[package]]
name = "durationpy"
version = "0.10"
@@ -2825,21 +3189,16 @@ testing = ["hatch", "pre-commit", "pytest", "tox"]
[[package]]
name = "filelock"
-version = "3.12.4"
+version = "3.20.3"
description = "A platform independent file lock."
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
- {file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"},
- {file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"},
+ {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
+ {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
]
-[package.extras]
-docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"]
-testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"]
-typing = ["typing-extensions (>=4.7.1) ; python_version < \"3.11\""]
-
[[package]]
name = "flask"
version = "3.1.2"
@@ -3147,6 +3506,8 @@ files = [
[package.dependencies]
google-auth = ">=2.14.1,<3.0.0"
googleapis-common-protos = ">=1.56.2,<2.0.0"
+grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
+grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
proto-plus = ">=1.22.3,<2.0.0"
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
requests = ">=2.18.0,<3.0.0"
@@ -3219,6 +3580,103 @@ files = [
google-auth = "*"
httplib2 = ">=0.19.0"
+[[package]]
+name = "google-cloud-access-context-manager"
+version = "0.3.0"
+description = "Google Cloud Access Context Manager Protobufs"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "google_cloud_access_context_manager-0.3.0-py3-none-any.whl", hash = "sha256:5d15ad51547f06c281e35f16b4ffcb3e98bb2d898b01470f88b94edfb2eeb0a3"},
+ {file = "google_cloud_access_context_manager-0.3.0.tar.gz", hash = "sha256:f3aa35c9225b7aaef85ecdacedcc1577789be8d458b7a41b6ad23b504786e5f9"},
+]
+
+[package.dependencies]
+google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]}
+protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
+
+[[package]]
+name = "google-cloud-asset"
+version = "4.1.0"
+description = "Google Cloud Asset API client library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "google_cloud_asset-4.1.0-py3-none-any.whl", hash = "sha256:2ce567bf002ad5099e173f6106393a7abc4782598c5d0d27de7d86ac26736e06"},
+ {file = "google_cloud_asset-4.1.0.tar.gz", hash = "sha256:00ba110085ff9f284b49961bcb9d2da5b5863fb91643c16d173ed38d73bfe35c"},
+]
+
+[package.dependencies]
+google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]}
+google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
+google-cloud-access-context-manager = ">=0.1.2,<1.0.0"
+google-cloud-org-policy = ">=0.1.2,<2.0.0"
+google-cloud-os-config = ">=1.0.0,<2.0.0"
+grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
+grpcio = ">=1.33.2,<2.0.0"
+proto-plus = ">=1.22.3,<2.0.0"
+protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
+
+[[package]]
+name = "google-cloud-org-policy"
+version = "1.15.0"
+description = "Google Cloud Org Policy API client library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "google_cloud_org_policy-1.15.0-py3-none-any.whl", hash = "sha256:5da410288236b334b8d05010501ea6180c5dc9e30888ff09488f2f107632f35b"},
+ {file = "google_cloud_org_policy-1.15.0.tar.gz", hash = "sha256:271d16a10e75347eace60d02cde322b2b1b613bcc99917109e0ebf2a4102253a"},
+]
+
+[package.dependencies]
+google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]}
+google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
+grpcio = ">=1.33.2,<2.0.0"
+proto-plus = ">=1.22.3,<2.0.0"
+protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
+
+[[package]]
+name = "google-cloud-os-config"
+version = "1.22.0"
+description = "Google Cloud Os Config API client library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "google_cloud_os_config-1.22.0-py3-none-any.whl", hash = "sha256:92afa402aa3b94d765751907fded1ef5908a6a5322f1fc88dee9e4c7f1cd7e54"},
+ {file = "google_cloud_os_config-1.22.0.tar.gz", hash = "sha256:d79a310f6fa1ce7470aaa084c70e38dc05d98531f468f821b3a526e4d33a70e4"},
+]
+
+[package.dependencies]
+google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]}
+google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
+grpcio = ">=1.33.2,<2.0.0"
+proto-plus = ">=1.22.3,<2.0.0"
+protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
+
+[[package]]
+name = "google-cloud-resource-manager"
+version = "1.15.0"
+description = "Google Cloud Resource Manager API client library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "google_cloud_resource_manager-1.15.0-py3-none-any.whl", hash = "sha256:0ccde5db644b269ddfdf7b407a2c7b60bdbf459f8e666344a5285601d00c7f6d"},
+ {file = "google_cloud_resource_manager-1.15.0.tar.gz", hash = "sha256:3d0b78c3daa713f956d24e525b35e9e9a76d597c438837171304d431084cedaf"},
+]
+
+[package.dependencies]
+google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]}
+google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
+grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
+grpcio = ">=1.33.2,<2.0.0"
+proto-plus = ">=1.22.3,<2.0.0"
+protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
+
[[package]]
name = "googleapis-common-protos"
version = "1.70.0"
@@ -3232,6 +3690,7 @@ files = [
]
[package.dependencies]
+grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""}
protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
[package.extras]
@@ -3346,6 +3805,117 @@ files = [
docs = ["Sphinx", "furo"]
test = ["objgraph", "psutil", "setuptools"]
+[[package]]
+name = "grpc-google-iam-v1"
+version = "0.14.3"
+description = "IAM API client library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6"},
+ {file = "grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389"},
+]
+
+[package.dependencies]
+googleapis-common-protos = {version = ">=1.56.0,<2.0.0", extras = ["grpc"]}
+grpcio = ">=1.44.0,<2.0.0"
+protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
+
+[[package]]
+name = "grpcio"
+version = "1.76.0"
+description = "HTTP/2-based RPC framework"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"},
+ {file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"},
+ {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"},
+ {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"},
+ {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"},
+ {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"},
+ {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"},
+ {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"},
+ {file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"},
+ {file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"},
+ {file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"},
+ {file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"},
+ {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"},
+ {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"},
+ {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"},
+ {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"},
+ {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"},
+ {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"},
+ {file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"},
+ {file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"},
+ {file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"},
+ {file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"},
+ {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"},
+ {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"},
+ {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"},
+ {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"},
+ {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"},
+ {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"},
+ {file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"},
+ {file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"},
+ {file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"},
+ {file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"},
+ {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"},
+ {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"},
+ {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"},
+ {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"},
+ {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"},
+ {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"},
+ {file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"},
+ {file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"},
+ {file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"},
+ {file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"},
+ {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"},
+ {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"},
+ {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"},
+ {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"},
+ {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"},
+ {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"},
+ {file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"},
+ {file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"},
+ {file = "grpcio-1.76.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:8ebe63ee5f8fa4296b1b8cfc743f870d10e902ca18afc65c68cf46fd39bb0783"},
+ {file = "grpcio-1.76.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:3bf0f392c0b806905ed174dcd8bdd5e418a40d5567a05615a030a5aeddea692d"},
+ {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b7604868b38c1bfd5cf72d768aedd7db41d78cb6a4a18585e33fb0f9f2363fd"},
+ {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e6d1db20594d9daba22f90da738b1a0441a7427552cc6e2e3d1297aeddc00378"},
+ {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d099566accf23d21037f18a2a63d323075bebace807742e4b0ac210971d4dd70"},
+ {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebea5cc3aa8ea72e04df9913492f9a96d9348db876f9dda3ad729cfedf7ac416"},
+ {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0c37db8606c258e2ee0c56b78c62fc9dee0e901b5dbdcf816c2dd4ad652b8b0c"},
+ {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebebf83299b0cb1721a8859ea98f3a77811e35dce7609c5c963b9ad90728f886"},
+ {file = "grpcio-1.76.0-cp39-cp39-win32.whl", hash = "sha256:0aaa82d0813fd4c8e589fac9b65d7dd88702555f702fb10417f96e2a2a6d4c0f"},
+ {file = "grpcio-1.76.0-cp39-cp39-win_amd64.whl", hash = "sha256:acab0277c40eff7143c2323190ea57b9ee5fd353d8190ee9652369fae735668a"},
+ {file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.12,<5.0"
+
+[package.extras]
+protobuf = ["grpcio-tools (>=1.76.0)"]
+
+[[package]]
+name = "grpcio-status"
+version = "1.76.0"
+description = "Status proto mapping for gRPC"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18"},
+ {file = "grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd"},
+]
+
+[package.dependencies]
+googleapis-common-protos = ">=1.5.5"
+grpcio = ">=1.76.0"
+protobuf = ">=6.31.1,<7.0.0"
+
[[package]]
name = "gunicorn"
version = "23.0.0"
@@ -3374,7 +3944,7 @@ version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -3414,7 +3984,7 @@ version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
@@ -3451,7 +4021,7 @@ version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -3471,6 +4041,21 @@ http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
+[[package]]
+name = "humanfriendly"
+version = "10.0"
+description = "Human friendly output for text interfaces using Python"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+groups = ["main"]
+files = [
+ {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"},
+ {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
+]
+
+[package.dependencies]
+pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""}
+
[[package]]
name = "hyperframe"
version = "6.1.0"
@@ -3714,6 +4299,37 @@ files = [
{file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"},
]
+[[package]]
+name = "joblib"
+version = "1.5.3"
+description = "Lightweight pipelining with Python functions"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"},
+ {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"},
+]
+
+[[package]]
+name = "jsonpickle"
+version = "4.1.1"
+description = "jsonpickle encodes/decodes any Python object to/from JSON"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91"},
+ {file = "jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1"},
+]
+
+[package.extras]
+cov = ["pytest-cov"]
+dev = ["black", "pyupgrade"]
+docs = ["furo", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
+packaging = ["build", "setuptools (>=61.2)", "setuptools_scm[toml] (>=6.0)", "twine"]
+testing = ["PyYAML", "atheris (>=2.3.0,<2.4.0) ; python_version < \"3.12\"", "bson", "ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=6.0,!=8.1.*)", "pytest-benchmark", "pytest-benchmark[histogram]", "pytest-checkdocs (>=1.2.3)", "pytest-enabler (>=1.0.1)", "pytest-ruff (>=0.2.1)", "scikit-learn", "scipy (>=1.9.3) ; python_version > \"3.10\"", "scipy ; python_version <= \"3.10\"", "simplejson", "sqlalchemy", "ujson"]
+
[[package]]
name = "jsonschema"
version = "4.23.0"
@@ -3862,6 +4478,26 @@ files = [
{file = "kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d"},
]
+[[package]]
+name = "knack"
+version = "0.11.0"
+description = "A Command-Line Interface framework"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "knack-0.11.0-py3-none-any.whl", hash = "sha256:6704c867840978a119a193914a90e2e98c7be7dff764c8fcd8a2286c5a978d00"},
+ {file = "knack-0.11.0.tar.gz", hash = "sha256:eb6568001e9110b1b320941431c51033d104cc98cda2254a5c2b09ba569fd494"},
+]
+
+[package.dependencies]
+argcomplete = "*"
+jmespath = "*"
+packaging = "*"
+pygments = "*"
+pyyaml = "*"
+tabulate = "*"
+
[[package]]
name = "kombu"
version = "5.5.4"
@@ -4175,7 +4811,7 @@ version = "4.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.10"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
@@ -4270,7 +4906,7 @@ version = "3.26.2"
description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
optional = false
python-versions = ">=3.9"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
{file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"},
{file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"},
@@ -4381,7 +5017,7 @@ version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
@@ -4501,21 +5137,38 @@ files = [
[package.dependencies]
microsoft-kiota-abstractions = ">=1.9.2,<1.10.0"
+[[package]]
+name = "microsoft-security-utilities-secret-masker"
+version = "1.0.0b4"
+description = "A tool for detecting and masking secrets"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "microsoft_security_utilities_secret_masker-1.0.0b4-py3-none-any.whl", hash = "sha256:0429fcaad10fc8ae3f940ab84fd2926e4f50ede134162144123b35937be831a8"},
+ {file = "microsoft_security_utilities_secret_masker-1.0.0b4.tar.gz", hash = "sha256:a30bd361ac18c8b52f6844076bc26465335949ea9c7a004d95f5196ec6fdef3e"},
+]
+
[[package]]
name = "msal"
-version = "1.33.0"
+version = "1.34.0b1"
description = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
- {file = "msal-1.33.0-py3-none-any.whl", hash = "sha256:c0cd41cecf8eaed733ee7e3be9e040291eba53b0f262d3ae9c58f38b04244273"},
- {file = "msal-1.33.0.tar.gz", hash = "sha256:836ad80faa3e25a7d71015c990ce61f704a87328b1e73bcbb0623a18cbf17510"},
+ {file = "msal-1.34.0b1-py3-none-any.whl", hash = "sha256:3b6373325e3509d97873e36965a75e9cc9393f1b579d12cc03c0ca0ef6d37eb4"},
+ {file = "msal-1.34.0b1.tar.gz", hash = "sha256:86cdbfec14955e803379499d017056c6df4ed40f717fd6addde94bdeb4babd78"},
]
[package.dependencies]
cryptography = ">=2.5,<48"
PyJWT = {version = ">=1.0.0,<3", extras = ["crypto"]}
+pymsalruntime = [
+ {version = ">=0.14,<0.19", optional = true, markers = "python_version >= \"3.6\" and platform_system == \"Windows\" and extra == \"broker\""},
+ {version = ">=0.17,<0.19", optional = true, markers = "python_version >= \"3.8\" and platform_system == \"Darwin\" and extra == \"broker\""},
+ {version = ">=0.18,<0.19", optional = true, markers = "python_version >= \"3.8\" and platform_system == \"Linux\" and extra == \"broker\""},
+]
requests = ">=2.0.0,<3"
[package.extras]
@@ -4523,21 +5176,19 @@ broker = ["pymsalruntime (>=0.14,<0.19) ; python_version >= \"3.6\" and platform
[[package]]
name = "msal-extensions"
-version = "1.3.1"
+version = "1.2.0"
description = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism."
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.7"
groups = ["main"]
files = [
- {file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"},
- {file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"},
+ {file = "msal_extensions-1.2.0-py3-none-any.whl", hash = "sha256:cf5ba83a2113fa6dc011a254a72f1c223c88d7dfad74cc30617c4679a417704d"},
+ {file = "msal_extensions-1.2.0.tar.gz", hash = "sha256:6f41b320bfd2933d631a215c91ca0dd3e67d84bd1a2f50ce917d5874ec646bef"},
]
[package.dependencies]
msal = ">=1.29,<2"
-
-[package.extras]
-portalocker = ["portalocker (>=1.4,<4)"]
+portalocker = ">=1.4,<3"
[[package]]
name = "msgraph-core"
@@ -4605,6 +5256,23 @@ requests-oauthlib = ">=0.5.0"
[package.extras]
async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""]
+[[package]]
+name = "msrestazure"
+version = "0.6.4.post1"
+description = "AutoRest swagger generator Python client runtime. Azure-specific module."
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "msrestazure-0.6.4.post1-py2.py3-none-any.whl", hash = "sha256:2264493b086c2a0a82ddf5fd87b35b3fffc443819127fed992ac5028354c151e"},
+ {file = "msrestazure-0.6.4.post1.tar.gz", hash = "sha256:39842007569e8c77885ace5c46e4bf2a9108fcb09b1e6efdf85b6e2c642b55d4"},
+]
+
+[package.dependencies]
+adal = ">=0.6.0,<2.0.0"
+msrest = ">=0.6.0,<2.0.0"
+six = "*"
+
[[package]]
name = "multidict"
version = "6.6.4"
@@ -4809,6 +5477,26 @@ pyspark = ["pyspark (>=3.5.0)"]
pyspark-connect = ["pyspark[connect] (>=3.5.0)"]
sqlframe = ["sqlframe (>=3.22.0)"]
+[[package]]
+name = "neo4j"
+version = "5.28.2"
+description = "Neo4j Bolt driver for Python"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "neo4j-5.28.2-py3-none-any.whl", hash = "sha256:5c53b5c3eee6dee7e920c9724391aa38d7135a651e71b766da00533b92a91a94"},
+ {file = "neo4j-5.28.2.tar.gz", hash = "sha256:7d38e27e4f987a45cc9052500c6ee27325cb23dae6509037fe31dd7ddaed70c7"},
+]
+
+[package.dependencies]
+pytz = "*"
+
+[package.extras]
+numpy = ["numpy (>=1.7.0,<3.0.0)"]
+pandas = ["numpy (>=1.7.0,<3.0.0)", "pandas (>=1.1.0,<3.0.0)"]
+pyarrow = ["pyarrow (>=1.0.0)"]
+
[[package]]
name = "nest-asyncio"
version = "1.6.0"
@@ -4821,6 +5509,32 @@ files = [
{file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"},
]
+[[package]]
+name = "nltk"
+version = "3.9.2"
+description = "Natural Language Toolkit"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"},
+ {file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"},
+]
+
+[package.dependencies]
+click = "*"
+joblib = "*"
+regex = ">=2021.8.3"
+tqdm = "*"
+
+[package.extras]
+all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"]
+corenlp = ["requests"]
+machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"]
+plot = ["matplotlib"]
+tgrep = ["pyparsing"]
+twitter = ["twython"]
+
[[package]]
name = "numpy"
version = "2.0.2"
@@ -4916,6 +5630,22 @@ pytz = ">=2016.10"
[package.extras]
adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""]
+[[package]]
+name = "okta"
+version = "0.0.4"
+description = "Okta client APIs"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "okta-0.0.4.tar.gz", hash = "sha256:53e792c68d3684ff4140b4cb1c02af3821090368f8110fde54c0bdb638449332"},
+]
+
+[package.dependencies]
+python-dateutil = ">=2.4.2"
+requests = ">=2.5.3"
+six = ">=1.9.0"
+
[[package]]
name = "openai"
version = "1.101.0"
@@ -5106,6 +5836,22 @@ files = [
[package.dependencies]
setuptools = "*"
+[[package]]
+name = "pdpyras"
+version = "5.4.1"
+description = "PagerDuty Python REST API Sessions."
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+ {file = "pdpyras-5.4.1-py2.py3-none-any.whl", hash = "sha256:e16020cf57e4c916ab3dace7c7dffe21a2e7059ab7411ce3ddf1e620c54e9c89"},
+ {file = "pdpyras-5.4.1.tar.gz", hash = "sha256:36021aff5979a79f1d87edc95e0c46e98ce8549292bc0cab3d9f33501795703b"},
+]
+
+[package.dependencies]
+requests = "*"
+urllib3 = "*"
+
[[package]]
name = "pillow"
version = "11.3.0"
@@ -5231,6 +5977,21 @@ tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "ole
typing = ["typing-extensions ; python_version < \"3.10\""]
xmp = ["defusedxml"]
+[[package]]
+name = "pkginfo"
+version = "1.12.1.2"
+description = "Query metadata from sdists / bdists / installed packages."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"},
+ {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"},
+]
+
+[package.extras]
+testing = ["pytest", "pytest-cov", "wheel"]
+
[[package]]
name = "platformdirs"
version = "4.3.8"
@@ -5288,6 +6049,42 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
+[[package]]
+name = "policyuniverse"
+version = "1.5.1.20231109"
+description = "Parse and Process AWS IAM Policies, Statements, ARNs, and wildcards."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "policyuniverse-1.5.1.20231109-py2.py3-none-any.whl", hash = "sha256:0b0ece0ee8285af31fc39ce09c82a551ca62e62bc2842e23952503bccb973321"},
+ {file = "policyuniverse-1.5.1.20231109.tar.gz", hash = "sha256:74e56d410560915c2c5132e361b0130e4bffe312a2f45230eac50d7c094bc40a"},
+]
+
+[package.extras]
+dev = ["black", "pre-commit"]
+tests = ["bandit", "coveralls", "pytest"]
+
+[[package]]
+name = "portalocker"
+version = "2.10.1"
+description = "Wraps the portalocker recipe for easy usage"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "portalocker-2.10.1-py3-none-any.whl", hash = "sha256:53a5984ebc86a025552264b459b46a2086e269b21823cb572f8f28ee759e45bf"},
+ {file = "portalocker-2.10.1.tar.gz", hash = "sha256:ef1bf844e878ab08aee7e40184156e1151f228f103aa5c6bd0724cc330960f8f"},
+]
+
+[package.dependencies]
+pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""}
+
+[package.extras]
+docs = ["sphinx (>=1.7.1)"]
+redis = ["redis"]
+tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)", "types-redis"]
+
[[package]]
name = "prompt-toolkit"
version = "3.0.51"
@@ -5501,8 +6298,9 @@ azure-mgmt-subscription = "3.1.1"
azure-mgmt-web = "8.0.0"
azure-monitor-query = "2.0.0"
azure-storage-blob = "12.24.1"
-boto3 = "1.39.15"
-botocore = "1.39.15"
+boto3 = "1.40.61"
+botocore = "1.40.61"
+cloudflare = "4.3.1"
colorama = "0.4.6"
cryptography = "44.0.1"
dash = "3.1.1"
@@ -5528,7 +6326,7 @@ python-dateutil = ">=2.9.0.post0,<3.0.0"
pytz = "2025.1"
schema = "0.7.5"
shodan = "1.31.0"
-slack-sdk = "3.34.0"
+slack-sdk = "3.39.0"
tabulate = "0.9.0"
tzlocal = "5.3.1"
@@ -5536,37 +6334,38 @@ tzlocal = "5.3.1"
type = "git"
url = "https://github.com/prowler-cloud/prowler.git"
reference = "master"
-resolved_reference = "d7f0b5b19094f92b460ee266935d2db4c4ad1de9"
+resolved_reference = "2c4f866e42c25973d6eb3dc7d58e7ce72b1ed3f0"
[[package]]
name = "psutil"
-version = "6.0.0"
+version = "6.1.1"
description = "Cross-platform lib for process and system monitoring in Python."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
-groups = ["main", "dev"]
+groups = ["main"]
files = [
- {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"},
- {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"},
- {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"},
- {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"},
- {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"},
- {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"},
- {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"},
- {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"},
- {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"},
- {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"},
- {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"},
- {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"},
- {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"},
- {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"},
- {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"},
- {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"},
- {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"},
+ {file = "psutil-6.1.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8"},
+ {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777"},
+ {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4"},
+ {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468"},
+ {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca"},
+ {file = "psutil-6.1.1-cp27-none-win32.whl", hash = "sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac"},
+ {file = "psutil-6.1.1-cp27-none-win_amd64.whl", hash = "sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030"},
+ {file = "psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8"},
+ {file = "psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377"},
+ {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003"},
+ {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160"},
+ {file = "psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3"},
+ {file = "psutil-6.1.1-cp36-cp36m-win32.whl", hash = "sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603"},
+ {file = "psutil-6.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303"},
+ {file = "psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53"},
+ {file = "psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649"},
+ {file = "psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5"},
]
[package.extras]
-test = ["enum34 ; python_version <= \"3.4\"", "ipaddress ; python_version < \"3.0\"", "mock ; python_version < \"3.0\"", "pywin32 ; sys_platform == \"win32\"", "wmi ; sys_platform == \"win32\""]
+dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"]
+test = ["pytest", "pytest-xdist", "setuptools"]
[[package]]
name = "psycopg2-binary"
@@ -5650,6 +6449,18 @@ files = [
{file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"},
]
+[[package]]
+name = "py-deviceid"
+version = "0.1.1"
+description = "A simple library to get or create a unique device id for a device in Python."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "py_deviceid-0.1.1-py3-none-any.whl", hash = "sha256:c0e32815e87a08087a0811c18f4402ee88b28a321f997753d75ecdaab570321b"},
+ {file = "py_deviceid-0.1.1.tar.gz", hash = "sha256:c3e7577ada23666e7f39e69370dfdaa76fe9de79c02635376d6aa0229bfa30e3"},
+]
+
[[package]]
name = "py-iam-expand"
version = "0.1.0"
@@ -5684,14 +6495,14 @@ pydantic = ">=2.9.2,<3.0.0"
[[package]]
name = "pyasn1"
-version = "0.6.1"
+version = "0.6.2"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
- {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
+ {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
+ {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
]
[[package]]
@@ -5741,7 +6552,7 @@ description = "PycURL -- A Python Interface To The cURL library"
optional = false
python-versions = ">=3.5"
groups = ["main"]
-markers = "sys_platform != \"win32\" and platform_python_implementation == \"CPython\""
+markers = "platform_python_implementation == \"CPython\" and sys_platform != \"win32\""
files = [
{file = "pycurl-7.45.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c31b390f1e2cd4525828f1bb78c1f825c0aab5d1588228ed71b22c4784bdb593"},
{file = "pycurl-7.45.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:942b352b69184cb26920db48e0c5cb95af39874b57dbe27318e60f1e68564e37"},
@@ -5778,133 +6589,122 @@ files = [
[[package]]
name = "pydantic"
-version = "2.11.7"
+version = "2.9.2"
description = "Data validation using Python type hints"
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
- {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"},
- {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"},
+ {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"},
+ {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"},
]
[package.dependencies]
annotated-types = ">=0.6.0"
-pydantic-core = "2.33.2"
-typing-extensions = ">=4.12.2"
-typing-inspection = ">=0.4.0"
+pydantic-core = "2.23.4"
+typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""}
[package.extras]
email = ["email-validator (>=2.0.0)"]
-timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
+timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""]
[[package]]
name = "pydantic-core"
-version = "2.33.2"
+version = "2.23.4"
description = "Core functionality for Pydantic validation and serialization"
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
- {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
- {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
- {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
- {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
- {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
- {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
- {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
- {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
- {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
- {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
- {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
- {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
- {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
- {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
- {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
- {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
- {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
- {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
- {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
- {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
- {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
- {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
- {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
- {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
- {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
- {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
- {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
- {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
- {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
- {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
- {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
- {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
- {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
- {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
- {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
- {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
- {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
- {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
- {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
- {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
- {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
- {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
- {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
- {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
- {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
- {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
- {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
- {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
- {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
- {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"},
+ {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"},
+ {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"},
+ {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"},
+ {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"},
+ {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"},
+ {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"},
+ {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"},
+ {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"},
+ {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"},
+ {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"},
+ {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"},
+ {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"},
+ {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"},
+ {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"},
+ {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"},
+ {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"},
+ {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"},
+ {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"},
+ {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"},
+ {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"},
+ {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"},
+ {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"},
+ {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"},
+ {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"},
+ {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"},
+ {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"},
+ {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"},
+ {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"},
+ {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"},
+ {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"},
+ {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"},
+ {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"},
+ {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"},
+ {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"},
+ {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"},
]
[package.dependencies]
@@ -5936,7 +6736,7 @@ version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
@@ -5994,6 +6794,47 @@ tomlkit = ">=0.10.1"
spelling = ["pyenchant (>=3.2,<4.0)"]
testutils = ["gitpython (>3)"]
+[[package]]
+name = "pymsalruntime"
+version = "0.18.1"
+description = "The MSALRuntime Python Interop Package"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+markers = "(platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\") and sys_platform == \"win32\""
+files = [
+ {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0c22e2e83faa10de422bbfaacc1bb2887c9025ee8a53f0fc2e4f7db01c4a7b66"},
+ {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8ce2944a0f944833d047bb121396091e00287e2b6373716106da86ea99abf379"},
+ {file = "pymsalruntime-0.18.1-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:9f7945ae0ee78357e9ca87d381f1c19763629a7197391ae7f84f4967a9f06e5b"},
+ {file = "pymsalruntime-0.18.1-cp310-cp310-win32.whl", hash = "sha256:10020abdfc34bbbf3414b86359de551d2d8bc7c241bc38c59a2468c4d49f21d5"},
+ {file = "pymsalruntime-0.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:f9aec2f44470d71feae35b611d1d8f15a549d96446e4f60e1ca1fb71856fffed"},
+ {file = "pymsalruntime-0.18.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e9320fb187fe1298d2165fa248af00907ca15d3a903a1d35fed86f6bc20b5880"},
+ {file = "pymsalruntime-0.18.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:9b2cecf3a570b7812d2007764df6dfbc27fca401a0d74532d5403aa20a9ef380"},
+ {file = "pymsalruntime-0.18.1-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:6f66fd99668abc3d4b8d93a9eb80c75178dc63186c79e6dbe133427b279835e0"},
+ {file = "pymsalruntime-0.18.1-cp311-cp311-win32.whl", hash = "sha256:74416947b1071054f3258cac3448a7adf708888727bf283267df2bb27f0998f1"},
+ {file = "pymsalruntime-0.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:beb926655aae3367b7e4bda2baad86f9271beefee1121f71642da0ed4de37fd2"},
+ {file = "pymsalruntime-0.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a6c07651cf4e07690d1b022da0977f56820ef553ac6dcbf4c9e68e9611020997"},
+ {file = "pymsalruntime-0.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:0b6c4f54ec13309cc7b717ac8760c2d9856d4924cefa2b794b6d03db4cfdeef8"},
+ {file = "pymsalruntime-0.18.1-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:06c73a47f024fcf36006b89fe32f2f6f6a004aa661cf8a03d3e496d1ef84cfe8"},
+ {file = "pymsalruntime-0.18.1-cp312-cp312-win32.whl", hash = "sha256:ace12bf9b7fcbf1bf21a03c227717e09ba99acd9190623fe0821a08832ece4eb"},
+ {file = "pymsalruntime-0.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:f9fd8ea52395f52f7d62498e47754adf2bfe6530816ff57eff1ba6f524aee51b"},
+ {file = "pymsalruntime-0.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:047a98b6709cddf6a1f50f78ee16d06fea0f42a44971b6d3e2988537277a1a17"},
+ {file = "pymsalruntime-0.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:910e653c65cd66fa9ce46dec103d3948da2276f7d4d315631a145eaab968d9a8"},
+ {file = "pymsalruntime-0.18.1-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:7ae0b160983ea0715d8ac69b441bbd29e7a9f31c9a5a2c350c79a794f5599f38"},
+ {file = "pymsalruntime-0.18.1-cp313-cp313-win32.whl", hash = "sha256:adf4200a1b423fe5d8e984c142cc64f0b76a9b0f7f8ff767490a2dde94fa642b"},
+ {file = "pymsalruntime-0.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:5a759aa551d084b160799f6df59c9891898ab305eb75ff1705bf04281675eb4b"},
+ {file = "pymsalruntime-0.18.1-cp38-cp38-macosx_14_0_arm64.whl", hash = "sha256:12b8990c4da1327ea46f6271bd57b28a90d3e795deacb370052914c3ff40d4c5"},
+ {file = "pymsalruntime-0.18.1-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:8dd68f9fedc200950093378b30a2ade4517324cef060788a759b575ea58dc6b2"},
+ {file = "pymsalruntime-0.18.1-cp38-cp38-manylinux_2_35_x86_64.whl", hash = "sha256:7183b1b1542a277db119fe55285c7609c661b8506b99cd7e53b7066ce6b838e4"},
+ {file = "pymsalruntime-0.18.1-cp38-cp38-win32.whl", hash = "sha256:56c3d708ba86311f049b004de81aa97655fed82782d3ec67e14ae1e27d4f5e5b"},
+ {file = "pymsalruntime-0.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:a8adc80fcf723b980976b81a0b409affe80f32d89ae6096d856fd20471d2f0c1"},
+ {file = "pymsalruntime-0.18.1-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:600d0f2b9b03dfb457ee1e13f191c2c217c0f6bceca512f1741e5215bc4bc5dc"},
+ {file = "pymsalruntime-0.18.1-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:daae8515ae8adac8662d8230f22af242f87c72d86f308ec51b7432f316199c1b"},
+ {file = "pymsalruntime-0.18.1-cp39-cp39-manylinux_2_35_x86_64.whl", hash = "sha256:864b8b9555a180c6baf8a57df3976b2e511582d54099561fbfe73f9f0b95c9f5"},
+ {file = "pymsalruntime-0.18.1-cp39-cp39-win32.whl", hash = "sha256:b90a3c8079ded9d5abc765bd90fdc34f6e49412793740ddbc6122a601008d50f"},
+ {file = "pymsalruntime-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:852dc82b3eaad0cce2c583314705183bf216e7fa7178040defd3a13195c1c406"},
+]
+
[[package]]
name = "pynacl"
version = "1.6.2"
@@ -6070,6 +6911,35 @@ files = [
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
+[[package]]
+name = "pyreadline3"
+version = "3.5.4"
+description = "A python implementation of GNU readline."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+markers = "sys_platform == \"win32\""
+files = [
+ {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"},
+ {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"},
+]
+
+[package.extras]
+dev = ["build", "flake8", "mypy", "pytest", "twine"]
+
+[[package]]
+name = "pysocks"
+version = "1.7.1"
+description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+groups = ["main"]
+files = [
+ {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"},
+ {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"},
+ {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"},
+]
+
[[package]]
name = "pytest"
version = "8.2.2"
@@ -6263,6 +7133,22 @@ files = [
[package.dependencies]
six = ">=1.5"
+[[package]]
+name = "python-digitalocean"
+version = "1.17.0"
+description = "digitalocean.com API to manage Droplets and Images"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "python-digitalocean-1.17.0.tar.gz", hash = "sha256:107854fde1aafa21774e8053cf253b04173613c94531f75d5a039ad770562b24"},
+ {file = "python_digitalocean-1.17.0-py3-none-any.whl", hash = "sha256:0032168e022e85fca314eb3f8dfaabf82087f2ed40839eb28f1eeeeca5afb1fa"},
+]
+
+[package.dependencies]
+jsonpickle = "*"
+requests = "*"
+
[[package]]
name = "python-memcached"
version = "1.62"
@@ -6315,7 +7201,6 @@ description = "Python for Window Extensions"
optional = false
python-versions = "*"
groups = ["main", "dev"]
-markers = "sys_platform == \"win32\""
files = [
{file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"},
{file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"},
@@ -6338,6 +7223,7 @@ files = [
{file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"},
{file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"},
]
+markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
[[package]]
name = "pyyaml"
@@ -6439,6 +7325,147 @@ attrs = ">=22.2.0"
rpds-py = ">=0.7.0"
typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
+[[package]]
+name = "regex"
+version = "2026.1.15"
+description = "Alternative regular expression module, to replace re."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e"},
+ {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f"},
+ {file = "regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b"},
+ {file = "regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c"},
+ {file = "regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9"},
+ {file = "regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c"},
+ {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106"},
+ {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618"},
+ {file = "regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4"},
+ {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79"},
+ {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9"},
+ {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220"},
+ {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13"},
+ {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3"},
+ {file = "regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218"},
+ {file = "regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a"},
+ {file = "regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3"},
+ {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a"},
+ {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f"},
+ {file = "regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1"},
+ {file = "regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b"},
+ {file = "regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8"},
+ {file = "regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413"},
+ {file = "regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026"},
+ {file = "regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785"},
+ {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e"},
+ {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763"},
+ {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb"},
+ {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2"},
+ {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1"},
+ {file = "regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569"},
+ {file = "regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7"},
+ {file = "regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec"},
+ {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1"},
+ {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681"},
+ {file = "regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f"},
+ {file = "regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa"},
+ {file = "regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804"},
+ {file = "regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c"},
+ {file = "regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5"},
+ {file = "regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3"},
+ {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb"},
+ {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410"},
+ {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4"},
+ {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d"},
+ {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22"},
+ {file = "regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913"},
+ {file = "regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a"},
+ {file = "regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056"},
+ {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e"},
+ {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10"},
+ {file = "regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc"},
+ {file = "regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599"},
+ {file = "regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae"},
+ {file = "regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5"},
+ {file = "regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6"},
+ {file = "regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788"},
+ {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714"},
+ {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d"},
+ {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3"},
+ {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31"},
+ {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3"},
+ {file = "regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f"},
+ {file = "regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e"},
+ {file = "regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337"},
+ {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be"},
+ {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8"},
+ {file = "regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd"},
+ {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a"},
+ {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93"},
+ {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af"},
+ {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09"},
+ {file = "regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5"},
+ {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794"},
+ {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a"},
+ {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80"},
+ {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2"},
+ {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60"},
+ {file = "regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952"},
+ {file = "regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10"},
+ {file = "regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829"},
+ {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac"},
+ {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6"},
+ {file = "regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2"},
+ {file = "regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846"},
+ {file = "regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b"},
+ {file = "regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e"},
+ {file = "regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde"},
+ {file = "regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5"},
+ {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34"},
+ {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75"},
+ {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e"},
+ {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160"},
+ {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1"},
+ {file = "regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1"},
+ {file = "regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903"},
+ {file = "regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705"},
+ {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8"},
+ {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf"},
+ {file = "regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d"},
+ {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84"},
+ {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df"},
+ {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434"},
+ {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a"},
+ {file = "regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10"},
+ {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac"},
+ {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea"},
+ {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e"},
+ {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521"},
+ {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db"},
+ {file = "regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e"},
+ {file = "regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf"},
+ {file = "regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70"},
+ {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:55b4ea996a8e4458dd7b584a2f89863b1655dd3d17b88b46cbb9becc495a0ec5"},
+ {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e1e28be779884189cdd57735e997f282b64fd7ccf6e2eef3e16e57d7a34a815"},
+ {file = "regex-2026.1.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0057de9eaef45783ff69fa94ae9f0fd906d629d0bd4c3217048f46d1daa32e9b"},
+ {file = "regex-2026.1.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7cd0b2be0f0269283a45c0d8b2c35e149d1319dcb4a43c9c3689fa935c1ee6"},
+ {file = "regex-2026.1.15-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8db052bbd981e1666f09e957f3790ed74080c2229007c1dd67afdbf0b469c48b"},
+ {file = "regex-2026.1.15-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:343db82cb3712c31ddf720f097ef17c11dab2f67f7a3e7be976c4f82eba4e6df"},
+ {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e9d0118d97794367309635df398bdfd7c33b93e2fdfa0b239661cd74b4c14e"},
+ {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:008b185f235acd1e53787333e5690082e4f156c44c87d894f880056089e9bc7c"},
+ {file = "regex-2026.1.15-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fd65af65e2aaf9474e468f9e571bd7b189e1df3a61caa59dcbabd0000e4ea839"},
+ {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f42e68301ff4afee63e365a5fc302b81bb8ba31af625a671d7acb19d10168a8c"},
+ {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f7792f27d3ee6e0244ea4697d92b825f9a329ab5230a78c1a68bd274e64b5077"},
+ {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dbaf3c3c37ef190439981648ccbf0c02ed99ae066087dd117fcb616d80b010a4"},
+ {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:adc97a9077c2696501443d8ad3fa1b4fc6d131fc8fd7dfefd1a723f89071cf0a"},
+ {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:069f56a7bf71d286a6ff932a9e6fb878f151c998ebb2519a9f6d1cee4bffdba3"},
+ {file = "regex-2026.1.15-cp39-cp39-win32.whl", hash = "sha256:ea4e6b3566127fda5e007e90a8fd5a4169f0cf0619506ed426db647f19c8454a"},
+ {file = "regex-2026.1.15-cp39-cp39-win_amd64.whl", hash = "sha256:cda1ed70d2b264952e88adaa52eea653a33a1b98ac907ae2f86508eb44f65cdc"},
+ {file = "regex-2026.1.15-cp39-cp39-win_arm64.whl", hash = "sha256:b325d4714c3c48277bfea1accd94e193ad6ed42b4bad79ad64f3b8f8a31260a5"},
+ {file = "regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5"},
+]
+
[[package]]
name = "reportlab"
version = "4.4.4"
@@ -6478,6 +7505,7 @@ files = [
certifi = ">=2017.4.17"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
+PySocks = {version = ">=1.5.6,<1.5.7 || >1.5.7", optional = true, markers = "extra == \"socks\""}
urllib3 = ">=1.21.1,<3"
[package.extras]
@@ -6536,7 +7564,7 @@ version = "14.1.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.8.0"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
{file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"},
{file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"},
@@ -6835,14 +7863,14 @@ files = [
[[package]]
name = "s3transfer"
-version = "0.13.1"
+version = "0.14.0"
description = "An Amazon S3 Transfer Manager"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724"},
- {file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"},
+ {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"},
+ {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"},
]
[package.dependencies]
@@ -6853,34 +7881,34 @@ crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
[[package]]
name = "safety"
-version = "3.2.9"
-description = "Checks installed dependencies for known vulnerabilities and licenses."
+version = "3.7.0"
+description = "Scan dependencies for known vulnerabilities and licenses."
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.9"
groups = ["dev"]
files = [
- {file = "safety-3.2.9-py3-none-any.whl", hash = "sha256:5e199c057550dc6146c081084274279dfb98c17735193b028db09a55ea508f1a"},
- {file = "safety-3.2.9.tar.gz", hash = "sha256:494bea752366161ac9e0742033d2a82e4dc51d7c788be42e0ecf5f3ef36b8071"},
+ {file = "safety-3.7.0-py3-none-any.whl", hash = "sha256:65e71db45eb832e8840e3456333d44c23927423753d5610596a09e909a66d2bf"},
+ {file = "safety-3.7.0.tar.gz", hash = "sha256:daec15a393cafc32b846b7ef93f9c952a1708863e242341ab5bde2e4beabb54e"},
]
[package.dependencies]
-Authlib = ">=1.2.0"
-Click = ">=8.0.2"
-dparse = ">=0.6.4b0"
-filelock = ">=3.12.2,<3.13.0"
+authlib = ">=1.2.0"
+click = ">=8.0.2"
+dparse = ">=0.6.4"
+filelock = ">=3.16.1,<4.0"
+httpx = "*"
jinja2 = ">=3.1.0"
marshmallow = ">=3.15.0"
+nltk = ">=3.9"
packaging = ">=21.0"
-psutil = ">=6.0.0,<6.1.0"
-pydantic = ">=1.10.12"
+pydantic = ">=2.6.0"
requests = "*"
-rich = "*"
-"ruamel.yaml" = ">=0.17.21"
-safety-schemas = ">=0.0.4"
-setuptools = ">=65.5.1"
-typer = "*"
+ruamel-yaml = ">=0.17.21"
+safety-schemas = "0.0.16"
+tenacity = ">=8.1.0"
+tomlkit = "*"
+typer = ">=0.16.0"
typing-extensions = ">=4.7.1"
-urllib3 = ">=1.26.5"
[package.extras]
github = ["pygithub (>=1.43.3)"]
@@ -6889,23 +7917,55 @@ spdx = ["spdx-tools (>=0.8.2)"]
[[package]]
name = "safety-schemas"
-version = "0.0.5"
+version = "0.0.16"
description = "Schemas for Safety tools"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
groups = ["dev"]
files = [
- {file = "safety_schemas-0.0.5-py3-none-any.whl", hash = "sha256:6ac9eb71e60f0d4e944597c01dd48d6d8cd3d467c94da4aba3702a05a3a6ab4f"},
- {file = "safety_schemas-0.0.5.tar.gz", hash = "sha256:0de5fc9a53d4423644a8ce9a17a2e474714aa27e57f3506146e95a41710ff104"},
+ {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"},
+ {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"},
]
[package.dependencies]
-dparse = ">=0.6.4b0"
+dparse = ">=0.6.4"
packaging = ">=21.0"
-pydantic = "*"
+pydantic = ">=2.6.0"
ruamel-yaml = ">=0.17.21"
typing-extensions = ">=4.7.1"
+[[package]]
+name = "scaleway"
+version = "2.10.3"
+description = "Scaleway SDK for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "scaleway-2.10.3-py3-none-any.whl", hash = "sha256:dbf381440d6caf37c878cf16445a63f4969a4aac2257c9b72c744d10ff223a0c"},
+ {file = "scaleway-2.10.3.tar.gz", hash = "sha256:b1f9dd1b1450767205234c6f5a345e5e25dc039c780253d698893b5c344ce594"},
+]
+
+[package.dependencies]
+scaleway-core = "2.10.3"
+
+[[package]]
+name = "scaleway-core"
+version = "2.10.3"
+description = "Scaleway SDK for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "scaleway_core-2.10.3-py3-none-any.whl", hash = "sha256:fd4112144554d6adae22ff737555eeb0e38cb1063250b3e88c9aebc1b957793b"},
+ {file = "scaleway_core-2.10.3.tar.gz", hash = "sha256:56432f755d694669429de51d51c1d0b3361b28dc2f939b28e4cb954610ee76be"},
+]
+
+[package.dependencies]
+python-dateutil = ">=2.8.2,<3.0.0"
+PyYAML = ">=6.0,<7.0"
+requests = ">=2.28.1,<3.0.0"
+
[[package]]
name = "schema"
version = "0.7.5"
@@ -7006,7 +8066,7 @@ version = "1.5.4"
description = "Tool to Detect Surrounding Shell"
optional = false
python-versions = ">=3.7"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
@@ -7045,18 +8105,18 @@ files = [
[[package]]
name = "slack-sdk"
-version = "3.34.0"
+version = "3.39.0"
description = "The Slack API Platform SDK for Python"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
groups = ["main"]
files = [
- {file = "slack_sdk-3.34.0-py2.py3-none-any.whl", hash = "sha256:c61f57f310d85be83466db5a98ab6ae3bb2e5587437b54fa0daa8fae6a0feffa"},
- {file = "slack_sdk-3.34.0.tar.gz", hash = "sha256:ff61db7012160eed742285ea91f11c72b7a38a6500a7f6c5335662b4bc6b853d"},
+ {file = "slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8"},
+ {file = "slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1"},
]
[package.extras]
-optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<15)"]
+optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<16)"]
[[package]]
name = "sniffio"
@@ -7064,7 +8124,7 @@ version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@@ -7086,6 +8146,18 @@ files = [
dev = ["build"]
doc = ["sphinx"]
+[[package]]
+name = "statsd"
+version = "4.0.1"
+description = "A simple statsd client."
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "statsd-4.0.1-py2.py3-none-any.whl", hash = "sha256:c2676519927f7afade3723aca9ca8ea986ef5b059556a980a867721ca69df093"},
+ {file = "statsd-4.0.1.tar.gz", hash = "sha256:99763da81bfea8daf6b3d22d11aaccb01a8d0f52ea521daab37e758a4ca7d128"},
+]
+
[[package]]
name = "std-uritemplate"
version = "2.0.5"
@@ -7134,7 +8206,7 @@ version = "9.1.2"
description = "Retry code until it succeeds"
optional = false
python-versions = ">=3.9"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"},
{file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"},
@@ -7206,7 +8278,7 @@ version = "0.16.1"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false
python-versions = ">=3.7"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
{file = "typer-0.16.1-py3-none-any.whl", hash = "sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9"},
{file = "typer-0.16.1.tar.gz", hash = "sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614"},
@@ -7218,6 +8290,21 @@ rich = ">=10.11.0"
shellingham = ">=1.3.0"
typing-extensions = ">=3.7.4.3"
+[[package]]
+name = "types-aiobotocore-ecr"
+version = "3.0.0"
+description = "Type annotations for aiobotocore ECR 3.0.0 service generated with mypy-boto3-builder 8.12.0"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "types_aiobotocore_ecr-3.0.0-py3-none-any.whl", hash = "sha256:06288369b9ddf78661224ac99a61aefe3c8a49e872d5c7a1626435ea848a817e"},
+ {file = "types_aiobotocore_ecr-3.0.0.tar.gz", hash = "sha256:a9f49aa3c83c6b6ab1cc7f10cc887ca35a549e0a29dfcdab40b285ce0846d06c"},
+]
+
+[package.dependencies]
+typing-extensions = {version = "*", markers = "python_version < \"3.12\""}
+
[[package]]
name = "typing-extensions"
version = "4.14.1"
@@ -7230,21 +8317,6 @@ files = [
{file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"},
]
-[[package]]
-name = "typing-inspection"
-version = "0.4.1"
-description = "Runtime typing introspection tools"
-optional = false
-python-versions = ">=3.9"
-groups = ["main", "dev"]
-files = [
- {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
- {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
-]
-
-[package.dependencies]
-typing-extensions = ">=4.12.0"
-
[[package]]
name = "tzdata"
version = "2025.2"
@@ -7563,6 +8635,21 @@ files = [
[package.dependencies]
lxml = ">=3.8"
+[[package]]
+name = "xmltodict"
+version = "1.0.2"
+description = "Makes working with XML feel like you are working with JSON"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d"},
+ {file = "xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649"},
+]
+
+[package.extras]
+test = ["pytest", "pytest-cov"]
+
[[package]]
name = "yarl"
version = "1.20.1"
@@ -7766,141 +8853,179 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[[package]]
name = "zstd"
-version = "1.5.7.2"
+version = "1.5.7.3"
description = "ZSTD Bindings for Python"
optional = false
python-versions = "*"
groups = ["main"]
files = [
- {file = "zstd-1.5.7.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e17104d0e88367a7571dde4286e233126c8551691ceff11f9ae2e3a3ac1bb483"},
- {file = "zstd-1.5.7.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:d6ee5dfada4c8fa32f43cc092fcf7d8482da6ad242c22fdf780f7eebd0febcc7"},
- {file = "zstd-1.5.7.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:ae1100776cb400100e2d2f427b50dc983c005c38cd59502eb56d2cfea3402ad5"},
- {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:489a0ff15caf7640851e63f85b680c4279c99094cd500a29c7ed3ab82505fce0"},
- {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:92590cf54318849d492445c885f1a42b9dbb47cdc070659c7cb61df6e8531047"},
- {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux_2_4_i686.whl", hash = "sha256:2bc21650f7b9c058a3c4cb503e906fe9cce293941ec1b48bc5d005c3b4422b42"},
- {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux_2_4_x86_64.whl", hash = "sha256:7b13e7eef9aa192804d38bf413924d347c6f6c6ac07f5a0c1ae4a6d7b3af70f0"},
- {file = "zstd-1.5.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3f14c5c405ea353b68fe105236780494eb67c756ecd346fd295498f5eab6d24"},
- {file = "zstd-1.5.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07d2061df22a3efc06453089e6e8b96e58f5bb7a0c4074dcfd0b0ce243ddde72"},
- {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:27e55aa2043ba7d8a08aba0978c652d4d5857338a8188aa84522569f3586c7bb"},
- {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e97933addfd71ea9608306f18dc18e7d2a5e64212ba2bb9a4ccb6d714f9f280"},
- {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:27e2ed58b64001c9ef0a8e028625477f1a6ed4ca949412ff6548544945cc59c2"},
- {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:92f072819fc0c7e8445f51a232c9ad76642027c069d2f36470cdb5e663839cdb"},
- {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2a653cdd2c52d60c28e519d44bde8d759f2c1837f0ff8e8e1b0045ca62fcf70e"},
- {file = "zstd-1.5.7.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:047803d87d910f4905f48d99aeff1e0539ec2e4f4bf17d077701b5d0b2392a95"},
- {file = "zstd-1.5.7.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0d8c1dc947e5ccea3bd81043080213685faf1d43886c27c51851fabf325f05c0"},
- {file = "zstd-1.5.7.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8291d393321fac30604c6bbf40067103fee315aa476647a5eaecf877ee53496f"},
- {file = "zstd-1.5.7.2-cp310-cp310-win32.whl", hash = "sha256:6922ceac5f2d60bb57a7875168c8aa442477b83e8951f2206cf1e9be788b0a6e"},
- {file = "zstd-1.5.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:346d1e4774d89a77d67fc70d53964bfca57c0abecfd885a4e00f87fd7c71e074"},
- {file = "zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b"},
- {file = "zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042"},
- {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773"},
- {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a"},
- {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6"},
- {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a"},
- {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806"},
- {file = "zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58"},
- {file = "zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7"},
- {file = "zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29"},
- {file = "zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b"},
- {file = "zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218"},
- {file = "zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734"},
- {file = "zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583"},
- {file = "zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac"},
- {file = "zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9"},
- {file = "zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50"},
- {file = "zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38"},
- {file = "zstd-1.5.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e684e27064b6550aa2e7dc85d171ea1b62cb5930a2c99b3df9b30bf620b5c06"},
- {file = "zstd-1.5.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd6262788a98807d6b2befd065d127db177c1cd76bb8e536e0dded419eb7c7fb"},
- {file = "zstd-1.5.7.2-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:53948be45f286a1b25c07a6aa2aca5c902208eb3df9fe36cf891efa0394c8b71"},
- {file = "zstd-1.5.7.2-cp313-cp313-win32.whl", hash = "sha256:edf816c218e5978033b7bb47dcb453dfb71038cb8a9bf4877f3f823e74d58174"},
- {file = "zstd-1.5.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:eea9bddf06f3f5e1e450fd647665c86df048a45e8b956d53522387c1dff41b7a"},
- {file = "zstd-1.5.7.2-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:1d71f9f92b3abe18b06b5f0aefa5b9c42112beef3bff27e36028d147cb4426a6"},
- {file = "zstd-1.5.7.2-cp314-cp314-manylinux_2_14_x86_64.whl", hash = "sha256:a6105b8fa21dbc59e05b6113e8e5d5aaf56c5d2886aa5778d61030af3256bbb7"},
- {file = "zstd-1.5.7.2-cp314-cp314t-manylinux_2_14_x86_64.whl", hash = "sha256:d0b0ca097efb5f67157c61a744c926848dcccf6e913df2f814e719aa78197a4b"},
- {file = "zstd-1.5.7.2-cp34-cp34m-manylinux_2_4_i686.whl", hash = "sha256:a371274668182ae06be2e321089b207fa0a75a58ae2fd4dfb7eafded9e041b2f"},
- {file = "zstd-1.5.7.2-cp34-cp34m-manylinux_2_4_x86_64.whl", hash = "sha256:74c3f006c9a3a191ed454183f0fb78172444f5cb431be04d85044a27f1b58c7b"},
- {file = "zstd-1.5.7.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:f19a3e658d92b6b52020c4c6d4c159480bcd3b47658773ea0e8d343cee849f33"},
- {file = "zstd-1.5.7.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d9d1bcb6441841c599883139c1b0e47bddb262cce04b37dc2c817da5802c1158"},
- {file = "zstd-1.5.7.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:bb1cb423fc40468cc9b7ab51a5b33c618eefd2c910a5bffed6ed76fe1cbb20b0"},
- {file = "zstd-1.5.7.2-cp35-cp35m-manylinux_2_14_x86_64.whl", hash = "sha256:e2476ba12597e58c5fc7a3ae547ee1bef9dd6b9d5ea80cf8d4034930c5a336e0"},
- {file = "zstd-1.5.7.2-cp35-cp35m-manylinux_2_4_i686.whl", hash = "sha256:2bf6447373782a2a9df3015121715f6d0b80a49a884c2d7d4518c9571e9fca16"},
- {file = "zstd-1.5.7.2-cp35-cp35m-win32.whl", hash = "sha256:a59a136a9eaa1849d715c004e30344177e85ad6e7bc4a5d0b6ad2495c5402675"},
- {file = "zstd-1.5.7.2-cp35-cp35m-win_amd64.whl", hash = "sha256:114115af8c68772a3205414597f626b604c7879f6662a2a79c88312e0f50361f"},
- {file = "zstd-1.5.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f576ec00e99db124309dac1e1f34bc320eb69624189f5fdaf9ebe1dc81581a84"},
- {file = "zstd-1.5.7.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f97d8593da0e23a47f148a1cb33300dccd513fb0df9f7911c274e228a8c1a300"},
- {file = "zstd-1.5.7.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:a130243e875de5aeda6099d12b11bc2fcf548dce618cf6b17f731336ba5338e4"},
- {file = "zstd-1.5.7.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:73cec37649fda383348dc8b3b5fba535f1dbb1bbaeb60fd36f4c145820208619"},
- {file = "zstd-1.5.7.2-cp36-cp36m-manylinux_2_14_x86_64.whl", hash = "sha256:883e7b77a3124011b8badd0c7c9402af3884700a3431d07877972e157d85afb8"},
- {file = "zstd-1.5.7.2-cp36-cp36m-manylinux_2_4_i686.whl", hash = "sha256:b5af6aa041b5515934afef2ef4af08566850875c3c890109088eedbe190eeefb"},
- {file = "zstd-1.5.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:53abf577aec7b30afa3c024143f4866676397c846b44f1b30d8097b5e4f5c7d7"},
- {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:660945ba16c16957c94dafc40aff1db02a57af0489aa3a896866239d47bb44b0"},
- {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3e220d2d7005822bb72a52e76410ca4634f941d8062c08e8e3285733c63b1db7"},
- {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_4_i686.whl", hash = "sha256:7e998f86a9d1e576c0158bf0b0a6a5c4685679d74ba0053a2e87f684f9bdc8eb"},
- {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_4_x86_64.whl", hash = "sha256:70d0c4324549073e05aa72e9eb6a593f89cba59da804b946d325d68467b93ad5"},
- {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:b9518caabf59405eddd667bbb161d9ae7f13dbf96967fd998d095589c8d41c86"},
- {file = "zstd-1.5.7.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:30d339d8e5c4b14c2015b50371fcdb8a93b451ca6d3ef813269ccbb8b3b3ef7d"},
- {file = "zstd-1.5.7.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:6f5539a10b838ee576084870eed65b63c13845e30a5b552cfe40f7e6b621e61a"},
- {file = "zstd-1.5.7.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:5540ce1c99fa0b59dad2eff771deb33872754000da875be50ac8c2beab42b433"},
- {file = "zstd-1.5.7.2-cp37-cp37m-win32.whl", hash = "sha256:56c4b8cd0a88fd721213661c28b87b64fbd14b6019df39b21b0117a68162b0f2"},
- {file = "zstd-1.5.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:594f256fa72852ade60e3acb909f983d5cf6839b9fc79728dd4b48b31112058f"},
- {file = "zstd-1.5.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9dc05618eb0abceb296b77e5f608669c12abc69cbf447d08151bcb14d290ab07"},
- {file = "zstd-1.5.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:70231ba799d681b6fc17456c3e39895c493b5dff400aa7842166322a952b7f2a"},
- {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5a73f0f20f71d4eef970a3fed7baac64d9a2a00b238acc4eca2bd7172bd7effb"},
- {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0a470f8938f69f632b8f88b96578a5e8825c18ddbbea7de63493f74874f963ef"},
- {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_4_i686.whl", hash = "sha256:d104f1cb2a7c142007c29a2a62dfe633155c648317a465674e583c295e5f792d"},
- {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_4_x86_64.whl", hash = "sha256:70f29e0504fc511d4b9f921e69637fca79c050e618ba23732a3f75c044814d89"},
- {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:a62c2f6f7b8fc69767392084828740bd6faf35ff54d4ccb2e90e199327c64140"},
- {file = "zstd-1.5.7.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f2dda0c76f87723fb7f75d7ad3bbd90f7fb47b75051978d22535099325111b41"},
- {file = "zstd-1.5.7.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f9cf09c2aa6f67750fe9f33fdd122f021b1a23bf7326064a8e21f7af7e77faee"},
- {file = "zstd-1.5.7.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:910bd9eac2488439f597504756b03c74aa63ed71b21e5d0aa2c7e249b3f1c13f"},
- {file = "zstd-1.5.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9838ec7eb9f1beb2f611b9bcac7a169cb3de708ccf779aead29787e4482fe232"},
- {file = "zstd-1.5.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:83a36bb1fd574422a77b36ccf3315ab687aef9a802b0c3312ca7006b74eeb109"},
- {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:6f8189bc58415758bbbd419695012194f5e5e22c34553712d9a3eb009c09808d"},
- {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:632e3c1b7e1ebb0580f6d92b781a8f7901d367cf72725d5642e6d3a32e404e45"},
- {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_4_i686.whl", hash = "sha256:df8083c40fdbfe970324f743f0b5ecc244c37736e5f3ad2670de61dde5e0b024"},
- {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_4_x86_64.whl", hash = "sha256:300db1ede4d10f8b9b3b99ca52b22f0e2303dc4f1cf6994d1f8345ce22dd5a7e"},
- {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:97b908ccb385047b0c020ce3dc55e6f51078c9790722fdb3620c076be4a69ecf"},
- {file = "zstd-1.5.7.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c59218bd36a7431a40591504f299de836ea0d63bc68ea76d58c4cf5262f0fa3c"},
- {file = "zstd-1.5.7.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4d5a85344193ec967d05da8e2c10aed400e2d83e16041d2fdfb713cfc8caceeb"},
- {file = "zstd-1.5.7.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebf6c1d7f0ceb0af5a383d2a1edc8ab9ace655e62a41c8a4ed5a031ee2ef8006"},
- {file = "zstd-1.5.7.2-cp39-cp39-win32.whl", hash = "sha256:44a5142123d59a0dbbd9ba9720c23521be57edbc24202223a5e17405c3bdd4a6"},
- {file = "zstd-1.5.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:8dc542a9818712a9fb37563fa88cdbbbb2b5f8733111d412b718fa602b83ba45"},
- {file = "zstd-1.5.7.2-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:24371a7b0475eef7d933c72067d363c5dc17282d2aa5d4f5837774378718509e"},
- {file = "zstd-1.5.7.2-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:c21d44981b068551f13097be3809fadb7f81617d0c21b2c28a7d04653dde958f"},
- {file = "zstd-1.5.7.2-pp27-pypy_73-manylinux_2_14_x86_64.whl", hash = "sha256:b011bf4cfad78cdf9116d6731234ff181deb9560645ffdcc8d54861ae5d1edfc"},
- {file = "zstd-1.5.7.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:426e5c6b7b3e2401b734bfd08050b071e17c15df5e3b31e63651d1fd9ba4c751"},
- {file = "zstd-1.5.7.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:53375b23f2f39359ade944169bbd88f8895eed91290ee608ccbc28810ac360ba"},
- {file = "zstd-1.5.7.2-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:1b301b2f9dbb0e848093127fb10cbe6334a697dc3aea6740f0bb726450ee9a34"},
- {file = "zstd-1.5.7.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5414c9ae27069ab3ec8420fe8d005cb1b227806cbc874a7b4c73a96b4697a633"},
- {file = "zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a"},
- {file = "zstd-1.5.7.2-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:9714d5642867fceb22e4ab74aebf81a2e62dc9206184d603cb39277b752d5885"},
- {file = "zstd-1.5.7.2-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:6584fd081a6e7d92dffa8e7373d1fced6b3cbf473154b82c17a99438c5e1de51"},
- {file = "zstd-1.5.7.2-pp36-pypy36_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:52f27a198e2a72632bae12ec63ebaa31b10e3d5f3dd3df2e01376979b168e2e6"},
- {file = "zstd-1.5.7.2-pp36-pypy36_pp73-win32.whl", hash = "sha256:3b14793d2a2cb3a7ddd1cf083321b662dd20bc11143abc719456e9bfd22a32aa"},
- {file = "zstd-1.5.7.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:faf3fd38ba26167c5a085c04b8c931a216f1baf072709db7a38e61dea52e316e"},
- {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:d17ac6d2584168247796174e599d4adbee00153246287e68881efaf8d48a6970"},
- {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9a24d492c63555b55e6bc73a9e82a38bf7c3e8f7cde600f079210ed19cb061f2"},
- {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c6abf4ab9a9d1feb14bc3cbcc32d723d340ce43b79b1812805916f3ac069b073"},
- {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:d7131bb4e55d075cb7847555a1e17fca5b816a550c9b9ac260c01799b6f8e8d9"},
- {file = "zstd-1.5.7.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a03608499794148f39c932c508d4eb3622e79ca2411b1d0438a2ee8cafdc0111"},
- {file = "zstd-1.5.7.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:86e64c71b4d00bf28be50e4941586e7874bdfa74858274d9f7571dd5dda92086"},
- {file = "zstd-1.5.7.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:0f79492bf86aef6e594b11e29c5589ddd13253db3ada0c7a14fb176b132fb65e"},
- {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:8c3f4bb8508bc54c00532931da4a5261f08493363da14a5526c986765973e35d"},
- {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:787bcf55cefc08d27aca34c6dcaae1a24940963d1a73d4cec894ee458c541ac4"},
- {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f97f872cb78a4fd60b6c1024a65a4c52a971e9d991f33c7acd833ee73050f85"},
- {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5e530b75452fdcff4ea67268d9e7cb37a38e7abbac84fa845205f0b36da81aaf"},
- {file = "zstd-1.5.7.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7c1cc65fc2789dd97a98202df840537de186ed04fd1804a17fcb15d1232442c4"},
- {file = "zstd-1.5.7.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:05604a693fa53b60ca083992324b08dafd15a4ac37ac4cffe4b43b9eb93d4440"},
- {file = "zstd-1.5.7.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:baf4e8b46d8934d4e85373f303eb048c63897fc4191d8ab301a1bbdf30b7a3cc"},
- {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:8cc35cc25e2d4a0f68020f05cba96912a2881ebaca890d990abe37aa3aa27045"},
- {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:ceae57e369e1b821b8f2b4c59bc08acd27d8e4bf9687bfa5211bc4cdb080fe7b"},
- {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5189fb44c44ab9b6c45f734bd7093a67686193110dc90dcfaf0e3a31b2385f38"},
- {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:f51a965871b25911e06d421212f9be7f7bcd3cedc43ea441a8a73fad9952baa0"},
- {file = "zstd-1.5.7.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:624022851c51dd6d6b31dbfd793347c4bd6339095e8383e2f74faf4f990b04c6"},
- {file = "zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a"},
+ {file = "zstd-1.5.7.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e72b353870286648a63261437b75f297e2967a26f210da4dfa4c08949935de7a"},
+ {file = "zstd-1.5.7.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:26aff5f24caeffde35f1b757499e935bc60a8e0d9e1ea8bde05dcf7d53df9325"},
+ {file = "zstd-1.5.7.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:586a820fbd06e3d9a9d9def572e779254bf8dee7406b8c6dc44eff6807d60c6d"},
+ {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:35a147b10fd16ebb3a2595e361780388feb8f336d70772a05dfb7a8348a47bfd"},
+ {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:c2a80c51e2175ffcd6f08b2a4c9fbc121aad69fbbcebb3364e783a96d0488fda"},
+ {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux_2_4_i686.whl", hash = "sha256:5f20f74a782f3296d1585d9bbc49d422e339b154c66398c74537e433446c51ba"},
+ {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux_2_4_x86_64.whl", hash = "sha256:2550c2e6bfbff0904f28821005f176bfdaec1872d60053665a284fb0254a10e7"},
+ {file = "zstd-1.5.7.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76f3535616887a1a38e8c6d0de693a23c5bb1f190651eb20d96bfc8e4ab706a0"},
+ {file = "zstd-1.5.7.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67507937e8e4c2a8dfed8e7fa77f4043ec9e6e831a5faebf0f99138b1a25ccbd"},
+ {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd0a2309c524608ce7b940abcc9f8eb5447c6ea2c834a630e0081211ab9d40ec"},
+ {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2b497306580d544406b5414c8485c4037a9283ad2ca6ae4ccdf3732c9563141d"},
+ {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:e9939a98ea946d1f9e8f9fecc940ae939b8e9e5ef9d71b104f7843567d764f30"},
+ {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:d32c0fe8f6b805b7cbeaade462b094a843e84d893d8c6f66ab705e8777cc1850"},
+ {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:8aa33b1ef24602b2ef1e8aa67ea3c8f821854a4dbf70c3c8c46b96b54b6ceb5d"},
+ {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1bd69fa9c4c97fd04206c919dedbf9f75f544ebb77880db51a13c1e3802cd655"},
+ {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aee96742a64ede2e35dc0316ef0cd1e50089e889ce77e82ca8edf40174a1439c"},
+ {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ac207573d2815a51f4f4fd4e255408396491729a01f690b9f5fb672d39e5610"},
+ {file = "zstd-1.5.7.3-cp310-cp310-win32.whl", hash = "sha256:04e62e4f9eba79699d072d3c96731ed4aff99f1d334eb967489b091186a6078f"},
+ {file = "zstd-1.5.7.3-cp310-cp310-win_amd64.whl", hash = "sha256:0794b23b9950af240888087d2bd5943aa4be67273ba32cdafabdc5704778b90e"},
+ {file = "zstd-1.5.7.3-cp310-cp310-win_arm64.whl", hash = "sha256:7827fd4901f3e71a7a755d26719549658f08e04fdf0870a952ed08e71b484435"},
+ {file = "zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a"},
+ {file = "zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7"},
+ {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5"},
+ {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5"},
+ {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424"},
+ {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7"},
+ {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5"},
+ {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb"},
+ {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc"},
+ {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e"},
+ {file = "zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d"},
+ {file = "zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3"},
+ {file = "zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527"},
+ {file = "zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203"},
+ {file = "zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8"},
+ {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54"},
+ {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304"},
+ {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699"},
+ {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8"},
+ {file = "zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41"},
+ {file = "zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328"},
+ {file = "zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3"},
+ {file = "zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c"},
+ {file = "zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f"},
+ {file = "zstd-1.5.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2455e56f1d265dacbd450510b8c2f632a5d8d92c23282e7723fb04af37001a2"},
+ {file = "zstd-1.5.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3486dc4f1b4e52bb059f8eec1f31daa3e540062c0f522f221782cf132a8bc9a8"},
+ {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_14_i686.whl", hash = "sha256:1cb47bf10ffcb6a782edacfe758da2c94879f7e89c6628feb3f1254daf8cc596"},
+ {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:07b1378d1230ddeea8773f99d7518a3060e6468c76edd502057cb795fe278d7e"},
+ {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ee34317f013e3405108f5baea53502159809cfc4510598d614257525500c70d"},
+ {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c19127ca2c79855376a34a2d7a6969408094b25c1f44485b0373eba4be851b98"},
+ {file = "zstd-1.5.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e79cae70dd08cb247391312463085c624c0302e8c860d13f87f4c76502d8202"},
+ {file = "zstd-1.5.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0e83e91e5daf89037c737f5529da0f80da80a78a6ad0b1d70a09860eb267dea4"},
+ {file = "zstd-1.5.7.3-cp313-cp313-win32.whl", hash = "sha256:2283f3bb910c028e1b9fe76b834016012ab021025a0ea197e27a1333f85e3031"},
+ {file = "zstd-1.5.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ad5fe4c36bab5dfa5a4b8d050bd07c50c1e69f94d381bc65337ab14cd69e5b1"},
+ {file = "zstd-1.5.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e878172b0eb69ac2edc6576eb862e00747c7c25e638fb354630a1ea7cfddf49"},
+ {file = "zstd-1.5.7.3-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:7e0a7e94d5b63b4cacf2396079ca9584d11f49f87cb4e5aa21f126a8f6b83446"},
+ {file = "zstd-1.5.7.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5412c86c34cbaf6906433ef3f2c96c407f208782f06cd3e5f01f066788adb3b8"},
+ {file = "zstd-1.5.7.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f94246befb1e473211a298c96e5768f3c63eaad814ac14d160d79ae9858e1d03"},
+ {file = "zstd-1.5.7.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31050e17a1a546fb82c90eee8ee3c30d22b9d0594b5937e69d38b7a5084af2a2"},
+ {file = "zstd-1.5.7.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ba8ec5dfd48c86d19f880713246f85d09ee06e8cd17141956258650878000d6"},
+ {file = "zstd-1.5.7.3-cp314-cp314-manylinux_2_14_i686.whl", hash = "sha256:3005540ba406157f3e205c998709ab5f8e68b390c658c7c238eb8986092089d5"},
+ {file = "zstd-1.5.7.3-cp314-cp314-manylinux_2_14_x86_64.whl", hash = "sha256:3934b54a3b7df039fcd4cf7b0f0a38c86ce44d26321255ffc3fac73d6cdcc59d"},
+ {file = "zstd-1.5.7.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e9230cd3e9153e2bed16f332558f8f3f7d869f4d15e8fa3f9c360bfa163a8b4a"},
+ {file = "zstd-1.5.7.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bffba70af539f14f9df5367b1add9119f14d5e35b658aef7b765417ea461e0e"},
+ {file = "zstd-1.5.7.3-cp314-cp314-win32.whl", hash = "sha256:a006e70c88ab67bb56989e11d820adc7601a6a7ad5558b3c6c690b19a1dadc5b"},
+ {file = "zstd-1.5.7.3-cp314-cp314-win_amd64.whl", hash = "sha256:cb4957c330c7b94b0546c7b9529723b49e865608683b9503a251fe793da9d4db"},
+ {file = "zstd-1.5.7.3-cp314-cp314-win_arm64.whl", hash = "sha256:a785426081ab7cafe4522876ac771d701766deea9a6d8352e87744da00e6637f"},
+ {file = "zstd-1.5.7.3-cp314-cp314t-manylinux_2_14_i686.whl", hash = "sha256:b52ef154793be0399befd742328ec6f5dff95154248d6d18dd65851cf22a1a5f"},
+ {file = "zstd-1.5.7.3-cp314-cp314t-manylinux_2_14_x86_64.whl", hash = "sha256:8024a8ba9156b1b2e64e69d147df5ddedeaed107f9da02a3428fd7baf3e5b920"},
+ {file = "zstd-1.5.7.3-cp315-cp315-manylinux_2_14_i686.whl", hash = "sha256:31ac7fbacca4759aad4b6abc13bbc05e68788e9e85a968255f7624b3b8db31df"},
+ {file = "zstd-1.5.7.3-cp315-cp315-manylinux_2_14_x86_64.whl", hash = "sha256:d03b2927c5843ded4d1319836a33a9c21675d2f86f916a2f234a060d4c67d87c"},
+ {file = "zstd-1.5.7.3-cp315-cp315t-manylinux_2_14_i686.whl", hash = "sha256:5dfbf2564eb574fc1f45613ecf28036a82533c3dd70e7bb1c9854168c638da7a"},
+ {file = "zstd-1.5.7.3-cp315-cp315t-manylinux_2_14_x86_64.whl", hash = "sha256:7f2f5776b902f41daf7b63e75a9384b0d7c855f824f14dabefc67814b8fa5611"},
+ {file = "zstd-1.5.7.3-cp34-cp34m-manylinux_2_4_i686.whl", hash = "sha256:ffbeabcabcb644d29289277f9023aa51c04de71935695f5388da9c8428c81e0f"},
+ {file = "zstd-1.5.7.3-cp34-cp34m-manylinux_2_4_x86_64.whl", hash = "sha256:0b891ca9ad84562941367ab7be817b8748df75eb6b7ced23d5b082b4602c1c6e"},
+ {file = "zstd-1.5.7.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:925f83e2e749cd7109985bc96835cd2fd814435d74f0d9a1d7c8506166e97592"},
+ {file = "zstd-1.5.7.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:57d2ff6b96886aaec2aa4721f7c8e890a8b43b5c4ae4f3737a0733b55cd82daa"},
+ {file = "zstd-1.5.7.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:8cd516ba02e0f9e6df1b4a6dc0cd5e66ac6eeb55b15833a70d529aa32eddaa91"},
+ {file = "zstd-1.5.7.3-cp35-cp35m-manylinux_2_14_x86_64.whl", hash = "sha256:9f6ea980866f43ff7ef5e41eac54b94f9159b9807f32f691b02ca381b50b76af"},
+ {file = "zstd-1.5.7.3-cp35-cp35m-manylinux_2_4_i686.whl", hash = "sha256:3e650ed68b655d55556099aa62f168a352396139a879a94312322a1d02502491"},
+ {file = "zstd-1.5.7.3-cp35-cp35m-win32.whl", hash = "sha256:da88b288a2844f04713df89a514dd9dc0e925ee63e119c845aef14ccbcc9183e"},
+ {file = "zstd-1.5.7.3-cp35-cp35m-win_amd64.whl", hash = "sha256:96c949e8508f2d4dced3444a3bfb99d51653ac6f28ef0aa1561f5758adc8afed"},
+ {file = "zstd-1.5.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7509b11b5f8313e87cce16269e222f89e7e49b51f1e6a3e7454b7c7b599d3211"},
+ {file = "zstd-1.5.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fb8aafd47ba73ff50a7994668dbec5c97f26ddcd28c03242d8f8b4138d8c723c"},
+ {file = "zstd-1.5.7.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:586efc62d7e93d52d0b3951ef48a4b5181866152061bda1bef49f7ea85ec0d7f"},
+ {file = "zstd-1.5.7.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5030d51631a09a0d7b3e47f928b6234bd78ce8b897a255fc1146e8cf772a8f4d"},
+ {file = "zstd-1.5.7.3-cp36-cp36m-manylinux_2_14_x86_64.whl", hash = "sha256:a8d1ee9faa89b21ff03ae3fe8d969e850c60b8c3f8a1389fa585c10eddaa2bb4"},
+ {file = "zstd-1.5.7.3-cp36-cp36m-manylinux_2_4_i686.whl", hash = "sha256:4504ba7a9ddd1919e919f81d3ec541313e6826f1f3cad8e3a7ebe29a3ae5cda6"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:aca7d1fef13f412168ac524307586f0d57f96a89bd7e0620b2f60df3b0066c8d"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:12d2925424d02add2f835c7549106151ece9eae262e96aee34af5d84178ba824"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:30512cce4108b26ede395ac521c0997c340bd19f177a1c0260bbffcb64861d30"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_4_i686.whl", hash = "sha256:2e6caf5f3084e6473a6dfd15285c47122ba92f4fb97ecfca855adf415603532a"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_4_x86_64.whl", hash = "sha256:927c95b991e81f39b02e42c9b391f2b3569e6dbe29d7fc2dce6ca778475c0934"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2174fd7f588b2eb95a402c3d40f4676370eb50292362a0995295084b8f5d521e"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:3b05817bfdfc395999b6b3c9ea4f7c05e91bceafc3fc819906d5f0445afa4335"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:c67f0fcf4348343d25ecd35a44d33b6d31814e9ab3ee8676039de809579905a4"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:40195c0056841aad6553172963adecf31b6ae1fdb9778d657ce9a2493d1791ee"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-win32.whl", hash = "sha256:b6ac3ae562758184fc1570399ea9d269163b488dbb0c4a44701e89f61ca6d1d6"},
+ {file = "zstd-1.5.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:e9f059d9c9f6f13ae78bfa9778755462b3ea53e4a5185941169422dd97c9fd22"},
+ {file = "zstd-1.5.7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:99e92b97c97d83e403615c12b644e8616fc7e8a8b4fa0c0558bcb9980baf5c92"},
+ {file = "zstd-1.5.7.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a6b4ff0d5704994eb0d7ba2ea0b25acd749bb78a1c325289a8cba7651f0cbbff"},
+ {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:edf4b595ab29a980f6f60fa71c64ab029d9ced97fb9c7c9ae555fe1159d8379d"},
+ {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3cd48ec1dce8a8a06a3978225b20f28b7764e4191c436277e0abc60539e040da"},
+ {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_4_i686.whl", hash = "sha256:1380ecc510a3885fad326863a7f42b3391560b471aeea60b04f9c1ece439b198"},
+ {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_4_x86_64.whl", hash = "sha256:5fdff5190698e6d48a3facb58085a6c33b62be610f40e80299d975dbc75b32c8"},
+ {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:595d6495e96744fa5c9b78f38e8379f9eebfb97ae4f7ecc2639af4fd51459e07"},
+ {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9bc3d6b7f2dec391b7539a0f43deb07bca1d68867082a07a286c2237f16390fd"},
+ {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:b8e62d533281946100c023a1168bd8935db6452bdd0f0b776afe8e80255e74c3"},
+ {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3a5dcc7ddcd56f131bee612b5feadd9b65e3996c0f4c6a485e2b2f20e7a324de"},
+ {file = "zstd-1.5.7.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dbb497482dd63abe72a209345dbafa52817bd484c1d08139da080c14b1dadc7b"},
+ {file = "zstd-1.5.7.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a599489d4e7e794981536521ee5dcfa61b0a641996409669b9aba5400b5cff83"},
+ {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4a7ec28ca27fc347d7325eeb06d66cd2649846d5bfe77b18beed38d1870dd876"},
+ {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:703481b41e5b3d33cd4e6a0b7116e8bc33a712aba1526d5fcad3e4303dd70fa1"},
+ {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_4_i686.whl", hash = "sha256:61b0707c090d59ba879eac4b475562c5b9c1b375d0419d78fb398f156037f7df"},
+ {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_4_x86_64.whl", hash = "sha256:7090ac97b14dea2969ba1ed427b38efe137efcdf556dc8740d3e035b04cbc8b4"},
+ {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5204bf9f3f2936ee3a28bfe43a57b78f88439c1777197295a0661d6de38caa80"},
+ {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:431d4fecf764c305f29c1b9117d0d2ec5eb5523fc81516f1ee82509cb3b8e088"},
+ {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2f213a32ab5e90bf165717f05fc1e3c214eeca7b6a33311e2397d89879c2f87"},
+ {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3f87d617dac84b571bb74dc9d6905c66906dca982143adbe8e497ba2ce888cca"},
+ {file = "zstd-1.5.7.3-cp39-cp39-win32.whl", hash = "sha256:9511957b5b8b5c0d4e737dff3a330a445a44005e09278bb8c799a76eb7f99d90"},
+ {file = "zstd-1.5.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:9389848cc8297199b0fe2cd2985e5944f611ed518aa508136065ea0159051904"},
+ {file = "zstd-1.5.7.3-cp39-cp39-win_arm64.whl", hash = "sha256:0cdf00f53cd38ce1f9edc79f68727150b9e65f4b33a3e8b59d94d0886cf43dbf"},
+ {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:c5ac39836233356d32d0fe3d2f9525373c47c19f75fde68c16cf2293b7648b86"},
+ {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:62fe5b560f389fdb40384a1711b7737bd9e27861f248cb89f19fed90a4cf0830"},
+ {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux_2_14_x86_64.whl", hash = "sha256:55fb8ac423800811f8b0c896b9617ecc91a1d4da15f66fb42ba162bfa5aa5a2d"},
+ {file = "zstd-1.5.7.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2b9ec4d5ba8c170d3fdf21ae5da3c15eaea2beef9c419a5f3274a6f9e03c412a"},
+ {file = "zstd-1.5.7.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7ab69fc4d90eeb64b98a567751f8e48373f4bcf301597fca344b8e8342e1d5e"},
+ {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da70f0918bf739bc75d7770410c9b94ea0dcb6f02d7ef70598b464bd5fcb193a"},
+ {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dd5c069d0409284f1963b0b6b119f21b1da9e22a503e88933eb0696249d87d3"},
+ {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46ca4a075f36f118e2ce07ba07d9ece7aeda193cea6f50b82aaee635df7b5fc2"},
+ {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:4a521cb7615fc61bfe9514bea182e224894b5987fc7843b6d6da20a61206ef24"},
+ {file = "zstd-1.5.7.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71ea22c953a164f34eb4b8c2c3b97eaa22da6a75296ea80b3ba4473187f15046"},
+ {file = "zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d"},
+ {file = "zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd"},
+ {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143"},
+ {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188"},
+ {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719"},
+ {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079"},
+ {file = "zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a"},
+ {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:781ec2644a3ce84c1cc19b0e057e1e8ea45260a8871eb6524614be75c9b432b9"},
+ {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:ab74f37f2832d4a7c89d877ed9a70b1ef988fc2353678a122427039eb1dc6e36"},
+ {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:521a3072fedcce025515d99242e346318d1815789033b7c0108796e151c42deb"},
+ {file = "zstd-1.5.7.3-pp36-pypy36_pp73-win32.whl", hash = "sha256:94d404fd56765ff2952053cb2f6f980b88e3384a71af147c3ede9f6c6bea32d6"},
+ {file = "zstd-1.5.7.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:33f7e24d626938234c3c33df1988b79846628cf08dfab216bb19f85e7fcad65b"},
+ {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:c0c84fd4a87f28b8bed01cbaf128d33dfa209f03df2890dbc8c01e17a109c2d4"},
+ {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:0e334e45becf5a4844c8d64593eb358585e1553a7355f2172c865efc639ac051"},
+ {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:15523e289509d7792418edb8c255cc1dacc65cda000428424c988208a682b8be"},
+ {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2924befc3cb1a2310e1c03bd93469a2de8f0703e8805fe1f40367fbc2cece472"},
+ {file = "zstd-1.5.7.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:173680156dbe959c80d72a1f15ef2034fd414b9d1ee507df152e416bc37665ef"},
+ {file = "zstd-1.5.7.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31d66b73a9861ee61bc6486fb9d1d33eabc86e506e49a210f30a91a241b8e643"},
+ {file = "zstd-1.5.7.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:a820a67491c1cf7a66698478a28b7d2517b0ae2e2775d834ca4f2624ba859e72"},
+ {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:c385f92c37f4275d477388e46af8941580d7eeaad4c524c8f9aa50d016acbc7e"},
+ {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:cecce78a3d639a3c439b1e355791e0f1ddbe8ed63d94f34c7973e92d384e6fc0"},
+ {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e769fc830f5e2079612a27d6540e4147cd8dc8beacfaf73a48152f30a191e979"},
+ {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:37a6750c25b561b05110313fdde4acd51246075a317e1c7a2491c96d2d863282"},
+ {file = "zstd-1.5.7.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0e95265e22f07cea6675baab762c9c4577a40d47824b01e0dcdf1a18b46aa041"},
+ {file = "zstd-1.5.7.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:878d859a7e1ebc078e0a575c05bcf3b0682b77cabd65bdbdd5e93c137ff1799b"},
+ {file = "zstd-1.5.7.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7efcf83189be9d842b9392ffd821b317cbd9447a49c590659abd3311e82c1676"},
+ {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:a75dfdbca7dc01e7b35ca9b22e5b9792037b1515857e67b34bd737b213e49432"},
+ {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5235dde49df717e5ca58f689e110bf1c4ed578170ab59e77f8a7a5055e4d8c07"},
+ {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f876acad51d2184269ee6fd7e4c4aad9b7a0eca174d7d8db981ea079b57cbaf4"},
+ {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2920e90ef200c7b2cbc73b4271c2271abf6195877b813ede0b5b76289e32fc8e"},
+ {file = "zstd-1.5.7.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1f6dd0f2845a9817f0d0920eb0efd2d8a0168b71b8d8c85d2655d9d997f127ba"},
+ {file = "zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495"},
]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.13"
-content-hash = "c40ff4d1cb06db047a7a39c35f6c765c259583c203da549146a0ed2e6d17a727"
+content-hash = "15b849034c0969b944c45f341169a42160efe2525fe60a4cadd8a8909202e9e3"
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 889a0eb519..4b3d9195d3 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -8,7 +8,7 @@ dependencies = [
"celery[pytest] (>=5.4.0,<6.0.0)",
"dj-rest-auth[with_social,jwt] (==7.0.1)",
"django (==5.1.15)",
- "django-allauth[saml] (>=65.8.0,<66.0.0)",
+ "django-allauth[saml] (>=65.13.0,<66.0.0)",
"django-celery-beat (>=2.7.0,<3.0.0)",
"django-celery-results (>=2.5.1,<3.0.0)",
"django-cors-headers==4.4.0",
@@ -36,6 +36,8 @@ dependencies = [
"drf-simple-apikey (==2.2.1)",
"matplotlib (>=3.10.6,<4.0.0)",
"reportlab (>=4.4.4,<5.0.0)",
+ "neo4j (<6.0.0)",
+ "cartography @ git+https://github.com/prowler-cloud/cartography@master",
"gevent (>=25.9.1,<26.0.0)",
"werkzeug (>=3.1.4)",
"sqlparse (>=0.5.4)",
@@ -47,7 +49,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
-version = "1.18.0"
+version = "1.19.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
@@ -68,6 +70,7 @@ pytest-env = "1.1.3"
pytest-randomly = "3.15.0"
pytest-xdist = "3.6.1"
ruff = "0.5.0"
-safety = "3.2.9"
-tqdm = "4.67.1"
+safety = "3.7.0"
+filelock = "3.20.3"
vulture = "2.14"
+tqdm = "4.67.1"
diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py
index add97cf376..543c10ab88 100644
--- a/api/src/backend/api/apps.py
+++ b/api/src/backend/api/apps.py
@@ -30,16 +30,48 @@ class ApiConfig(AppConfig):
def ready(self):
from api import schema_extensions # noqa: F401
from api import signals # noqa: F401
- from api.compliance import load_prowler_compliance
+ from api.attack_paths import database as graph_database
# Generate required cryptographic keys if not present, but only if:
- # `"manage.py" not in sys.argv`: If an external server (e.g., Gunicorn) is running the app
+ # `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app
# `os.environ.get("RUN_MAIN")`: If it's not a Django command or using `runserver`,
# only the main process will do it
- if "manage.py" not in sys.argv or os.environ.get("RUN_MAIN"):
+ if (len(sys.argv) >= 1 and "manage.py" not in sys.argv[0]) or os.environ.get(
+ "RUN_MAIN"
+ ):
self._ensure_crypto_keys()
- load_prowler_compliance()
+ # Commands that don't need Neo4j
+ SKIP_NEO4J_DJANGO_COMMANDS = [
+ "makemigrations",
+ "migrate",
+ "pgpartition",
+ "check",
+ "help",
+ "showmigrations",
+ "check_and_fix_socialaccount_sites_migration",
+ ]
+
+ # Skip Neo4j initialization during tests, some Django commands, and Celery
+ if getattr(settings, "TESTING", False) or (
+ len(sys.argv) > 1
+ and (
+ (
+ "manage.py" in sys.argv[0]
+ and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS
+ )
+ or "celery" in sys.argv[0]
+ )
+ ):
+ logger.info(
+ "Skipping Neo4j initialization because tests, some Django commands or Celery"
+ )
+
+ else:
+ graph_database.init_driver()
+
+ # Neo4j driver is initialized at API startup (see api.attack_paths.database)
+ # It remains lazy for Celery workers and selected Django commands
def _ensure_crypto_keys(self):
"""
@@ -54,7 +86,7 @@ class ApiConfig(AppConfig):
global _keys_initialized
# Skip key generation if running tests
- if hasattr(settings, "TESTING") and settings.TESTING:
+ if getattr(settings, "TESTING", False):
return
# Skip if already initialized in this process
diff --git a/api/src/backend/api/attack_paths/__init__.py b/api/src/backend/api/attack_paths/__init__.py
new file mode 100644
index 0000000000..2c3ea4c5d8
--- /dev/null
+++ b/api/src/backend/api/attack_paths/__init__.py
@@ -0,0 +1,13 @@
+from api.attack_paths.query_definitions import (
+ AttackPathsQueryDefinition,
+ AttackPathsQueryParameterDefinition,
+ get_queries_for_provider,
+ get_query_by_id,
+)
+
+__all__ = [
+ "AttackPathsQueryDefinition",
+ "AttackPathsQueryParameterDefinition",
+ "get_queries_for_provider",
+ "get_query_by_id",
+]
diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py
new file mode 100644
index 0000000000..08b5552054
--- /dev/null
+++ b/api/src/backend/api/attack_paths/database.py
@@ -0,0 +1,161 @@
+import atexit
+import logging
+import threading
+from contextlib import contextmanager
+from typing import Iterator
+from uuid import UUID
+
+import neo4j
+import neo4j.exceptions
+from django.conf import settings
+
+from api.attack_paths.retryable_session import RetryableSession
+
+# Without this Celery goes crazy with Neo4j logging
+logging.getLogger("neo4j").setLevel(logging.ERROR)
+logging.getLogger("neo4j").propagate = False
+
+SERVICE_UNAVAILABLE_MAX_RETRIES = 3
+
+# Module-level process-wide driver singleton
+_driver: neo4j.Driver | None = None
+_lock = threading.Lock()
+
+# Base Neo4j functions
+
+
+def get_uri() -> str:
+ host = settings.DATABASES["neo4j"]["HOST"]
+ port = settings.DATABASES["neo4j"]["PORT"]
+ return f"bolt://{host}:{port}"
+
+
+def init_driver() -> neo4j.Driver:
+ global _driver
+ if _driver is not None:
+ return _driver
+
+ with _lock:
+ if _driver is None:
+ uri = get_uri()
+ config = settings.DATABASES["neo4j"]
+
+ _driver = neo4j.GraphDatabase.driver(
+ uri,
+ auth=(config["USER"], config["PASSWORD"]),
+ keep_alive=True,
+ max_connection_lifetime=7200,
+ connection_acquisition_timeout=120,
+ max_connection_pool_size=50,
+ )
+ _driver.verify_connectivity()
+
+ # Register cleanup handler (only runs once since we're inside the _driver is None block)
+ atexit.register(close_driver)
+
+ return _driver
+
+
+def get_driver() -> neo4j.Driver:
+ return init_driver()
+
+
+def close_driver() -> None: # TODO: Use it
+ global _driver
+ with _lock:
+ if _driver is not None:
+ try:
+ _driver.close()
+
+ finally:
+ _driver = None
+
+
+@contextmanager
+def get_session(database: str | None = None) -> Iterator[RetryableSession]:
+ session_wrapper: RetryableSession | None = None
+
+ try:
+ session_wrapper = RetryableSession(
+ session_factory=lambda: get_driver().session(database=database),
+ max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES,
+ )
+ yield session_wrapper
+
+ except neo4j.exceptions.Neo4jError as exc:
+ raise GraphDatabaseQueryException(message=exc.message, code=exc.code)
+
+ finally:
+ if session_wrapper is not None:
+ session_wrapper.close()
+
+
+def create_database(database: str) -> None:
+ query = "CREATE DATABASE $database IF NOT EXISTS"
+ parameters = {"database": database}
+
+ with get_session() as session:
+ session.run(query, parameters)
+
+
+def drop_database(database: str) -> None:
+ query = f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA"
+
+ with get_session() as session:
+ session.run(query)
+
+
+def drop_subgraph(database: str, root_node_label: str, root_node_id: str) -> int:
+ query = """
+ MATCH (a:__ROOT_NODE_LABEL__ {id: $root_node_id})
+ CALL apoc.path.subgraphNodes(a, {})
+ YIELD node
+ DETACH DELETE node
+ RETURN COUNT(node) AS deleted_nodes_count
+ """.replace("__ROOT_NODE_LABEL__", root_node_label)
+ parameters = {"root_node_id": root_node_id}
+
+ with get_session(database) as session:
+ result = session.run(query, parameters)
+
+ try:
+ return result.single()["deleted_nodes_count"]
+
+ except neo4j.exceptions.ResultConsumedError:
+ return 0 # As there are no nodes to delete, the result is empty
+
+
+def clear_cache(database: str) -> None:
+ query = "CALL db.clearQueryCaches()"
+
+ try:
+ with get_session(database) as session:
+ session.run(query)
+
+ except GraphDatabaseQueryException as exc:
+ logging.warning(f"Failed to clear query cache for database `{database}`: {exc}")
+
+
+# Neo4j functions related to Prowler + Cartography
+DATABASE_NAME_TEMPLATE = "db-{attack_paths_scan_id}"
+
+
+def get_database_name(attack_paths_scan_id: UUID) -> str:
+ attack_paths_scan_id_str = str(attack_paths_scan_id).lower()
+ return DATABASE_NAME_TEMPLATE.format(attack_paths_scan_id=attack_paths_scan_id_str)
+
+
+# Exceptions
+
+
+class GraphDatabaseQueryException(Exception):
+ def __init__(self, message: str, code: str | None = None) -> None:
+ super().__init__(message)
+ self.message = message
+ self.code = code
+
+ def __str__(self) -> str:
+ if self.code:
+ return f"{self.code}: {self.message}"
+
+ return self.message
diff --git a/api/src/backend/api/attack_paths/query_definitions.py b/api/src/backend/api/attack_paths/query_definitions.py
new file mode 100644
index 0000000000..7e0f2068da
--- /dev/null
+++ b/api/src/backend/api/attack_paths/query_definitions.py
@@ -0,0 +1,690 @@
+from dataclasses import dataclass, field
+
+
+# Dataclases for handling API's Attack Path query definitions and their parameters
+@dataclass
+class AttackPathsQueryParameterDefinition:
+ """
+ Metadata describing a parameter that must be provided to an Attack Paths query.
+ """
+
+ name: str
+ label: str
+ data_type: str = "string"
+ cast: type = str
+ description: str | None = None
+ placeholder: str | None = None
+
+
+@dataclass
+class AttackPathsQueryDefinition:
+ """
+ Immutable representation of an Attack Path query.
+ """
+
+ id: str
+ name: str
+ description: str
+ provider: str
+ cypher: str
+ parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
+
+
+# Accessor functions for API's Attack Paths query definitions
+def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]:
+ return _QUERY_DEFINITIONS.get(provider, [])
+
+
+def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None:
+ return _QUERIES_BY_ID.get(query_id)
+
+
+# API's Attack Paths query definitions
+_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
+ "aws": [
+ # Custom query for detecting internet-exposed EC2 instances with sensitive S3 access
+ AttackPathsQueryDefinition(
+ id="aws-internet-exposed-ec2-sensitive-s3-access",
+ name="Identify internet-exposed EC2 instances with sensitive S3 access",
+ description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.",
+ provider="aws",
+ cypher="""
+ CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
+ YIELD node AS internet
+
+ MATCH path_s3 = (aws:AWSAccount {id: $provider_uid})--(s3:S3Bucket)--(t:AWSTag)
+ WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value)
+
+ MATCH path_ec2 = (aws)--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)
+ WHERE ec2.exposed_internet = true
+ AND ipi.toport = 22
+
+ MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name)
+ AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*')
+
+ MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole)
+
+ CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2)
+ YIELD rel AS can_access
+
+ UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[
+ AttackPathsQueryParameterDefinition(
+ name="tag_key",
+ label="Tag key",
+ description="Tag key to filter the S3 bucket, e.g. DataClassification.",
+ placeholder="DataClassification",
+ ),
+ AttackPathsQueryParameterDefinition(
+ name="tag_value",
+ label="Tag value",
+ description="Tag value to filter the S3 bucket, e.g. Sensitive.",
+ placeholder="Sensitive",
+ ),
+ ],
+ ),
+ # Regular Cartography Attack Paths queries
+ AttackPathsQueryDefinition(
+ id="aws-rds-instances",
+ name="Identify provisioned RDS instances",
+ description="List the selected AWS account alongside the RDS instances it owns.",
+ provider="aws",
+ cypher="""
+ MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-rds-unencrypted-storage",
+ name="Identify RDS instances without storage encryption",
+ description="Find RDS instances with storage encryption disabled within the selected account.",
+ provider="aws",
+ cypher="""
+ MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)
+ WHERE rds.storage_encrypted = false
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-s3-anonymous-access-buckets",
+ name="Identify S3 buckets with anonymous access",
+ description="Find S3 buckets that allow anonymous access within the selected account.",
+ provider="aws",
+ cypher="""
+ MATCH path = (aws:AWSAccount {id: $provider_uid})--(s3:S3Bucket)
+ WHERE s3.anonymous_access = true
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-iam-statements-allow-all-actions",
+ name="Identify IAM statements that allow all actions",
+ description="Find IAM policy statements that allow all actions via '*' within the selected account.",
+ provider="aws",
+ cypher="""
+ MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(x IN stmt.action WHERE x = '*')
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-iam-statements-allow-delete-policy",
+ name="Identify IAM statements that allow iam:DeletePolicy",
+ description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.",
+ provider="aws",
+ cypher="""
+ MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(x IN stmt.action WHERE x = "iam:DeletePolicy")
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-iam-statements-allow-create-actions",
+ name="Identify IAM statements that allow create actions",
+ description="Find IAM policy statements that allow actions containing 'create' within the selected account.",
+ provider="aws",
+ cypher="""
+ MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = "Allow"
+ AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create")
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-ec2-instances-internet-exposed",
+ name="Identify internet-exposed EC2 instances",
+ description="Find EC2 instances flagged as exposed to the internet within the selected account.",
+ provider="aws",
+ cypher="""
+ CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
+ YIELD node AS internet
+
+ MATCH path = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance)
+ WHERE ec2.exposed_internet = true
+
+ CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2)
+ YIELD rel AS can_access
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-security-groups-open-internet-facing",
+ name="Identify internet-facing resources with open security groups",
+ description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.",
+ provider="aws",
+ cypher="""
+ CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
+ YIELD node AS internet
+
+ // Match EC2 instances that are internet-exposed with open security groups (0.0.0.0/0)
+ MATCH path_ec2 = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange)
+ WHERE ec2.exposed_internet = true
+ AND ir.range = "0.0.0.0/0"
+
+ CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2)
+ YIELD rel AS can_access
+
+ UNWIND nodes(path_ec2) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path_ec2, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-classic-elb-internet-exposed",
+ name="Identify internet-exposed Classic Load Balancers",
+ description="Find Classic Load Balancers exposed to the internet along with their listeners.",
+ provider="aws",
+ cypher="""
+ CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
+ YIELD node AS internet
+
+ MATCH path = (aws:AWSAccount {id: $provider_uid})--(elb:LoadBalancer)--(listener:ELBListener)
+ WHERE elb.exposed_internet = true
+
+ CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, elb)
+ YIELD rel AS can_access
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-elbv2-internet-exposed",
+ name="Identify internet-exposed ELBv2 load balancers",
+ description="Find ELBv2 load balancers exposed to the internet along with their listeners.",
+ provider="aws",
+ cypher="""
+ CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
+ YIELD node AS internet
+
+ MATCH path = (aws:AWSAccount {id: $provider_uid})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener)
+ WHERE elbv2.exposed_internet = true
+
+ CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, elbv2)
+ YIELD rel AS can_access
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-public-ip-resource-lookup",
+ name="Identify resources by public IP address",
+ description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.",
+ provider="aws",
+ cypher="""
+ CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'})
+ YIELD node AS internet
+
+ CALL () {
+ MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:EC2PrivateIp)-[q]-(y)
+ WHERE x.public_ip = $ip
+ RETURN path, x
+
+ UNION MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:EC2Instance)-[q]-(y)
+ WHERE x.publicipaddress = $ip
+ RETURN path, x
+
+ UNION MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:NetworkInterface)-[q]-(y)
+ WHERE x.public_ip = $ip
+ RETURN path, x
+
+ UNION MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:ElasticIPAddress)-[q]-(y)
+ WHERE x.public_ip = $ip
+ RETURN path, x
+ }
+
+ WITH path, x, internet
+
+ CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, x)
+ YIELD rel AS can_access
+
+ UNWIND nodes(path) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[
+ AttackPathsQueryParameterDefinition(
+ name="ip",
+ label="IP address",
+ description="Public IP address, e.g. 192.0.2.0.",
+ placeholder="192.0.2.0",
+ ),
+ ],
+ ),
+ # Privilege Escalation Queries (based on pathfinding.cloud research): https://github.com/DataDog/pathfinding.cloud
+ AttackPathsQueryDefinition(
+ id="aws-iam-privesc-passrole-ec2",
+ name="Privilege Escalation: iam:PassRole + ec2:RunInstances",
+ description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service. This is a new-passrole escalation path (pathfinding.cloud: ec2-001).",
+ provider="aws",
+ cypher="""
+ // Create a single shared virtual EC2 instance node
+ CALL apoc.create.vNode(['EC2Instance'], {
+ id: 'potential-ec2-passrole',
+ name: 'New EC2 Instance',
+ description: 'Attacker-controlled EC2 with privileged role'
+ })
+ YIELD node AS ec2_node
+
+ // Create a single shared virtual escalation outcome node (styled like a finding)
+ CALL apoc.create.vNode(['PrivilegeEscalation'], {
+ id: 'effective-administrator-passrole-ec2',
+ check_title: 'Privilege Escalation',
+ name: 'Effective Administrator',
+ status: 'FAIL',
+ severity: 'critical'
+ })
+ YIELD node AS escalation_outcome
+
+ WITH ec2_node, escalation_outcome
+
+ // Find principals in the account
+ MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
+
+ // Find statements granting iam:PassRole
+ MATCH path_passrole = (principal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement)
+ WHERE stmt_passrole.effect = 'Allow'
+ AND any(action IN stmt_passrole.action WHERE
+ toLower(action) = 'iam:passrole'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find statements granting ec2:RunInstances
+ MATCH path_ec2 = (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement)
+ WHERE stmt_ec2.effect = 'Allow'
+ AND any(action IN stmt_ec2.action WHERE
+ toLower(action) = 'ec2:runinstances'
+ OR toLower(action) = 'ec2:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust EC2 service (can be passed to EC2)
+ MATCH path_target = (aws)--(target_role:AWSRole)
+ WHERE target_role.arn CONTAINS $provider_uid
+ // Check if principal can pass this role
+ AND any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ // Check if target role has elevated permissions (optional, for severity assessment)
+ OPTIONAL MATCH (target_role)--(role_policy:AWSPolicy)--(role_stmt:AWSPolicyStatement)
+ WHERE role_stmt.effect = 'Allow'
+ AND (
+ any(action IN role_stmt.action WHERE action = '*')
+ OR any(action IN role_stmt.action WHERE toLower(action) = 'iam:*')
+ )
+
+ CALL apoc.create.vRelationship(principal, 'CAN_LAUNCH', {
+ via: 'ec2:RunInstances + iam:PassRole'
+ }, ec2_node)
+ YIELD rel AS launch_rel
+
+ CALL apoc.create.vRelationship(ec2_node, 'ASSUMES_ROLE', {}, target_role)
+ YIELD rel AS assumes_rel
+
+ CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
+ reference: 'https://pathfinding.cloud/paths/ec2-001'
+ }, escalation_outcome)
+ YIELD rel AS grants_rel
+
+ UNWIND nodes(path_principal) + nodes(path_passrole) + nodes(path_ec2) + nodes(path_target) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path_principal, path_passrole, path_ec2, path_target,
+ ec2_node, escalation_outcome, launch_rel, assumes_rel, grants_rel,
+ collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-glue-privesc-passrole-dev-endpoint",
+ name="Privilege Escalation: Glue Dev Endpoint with PassRole",
+ description="Detect principals that can escalate privileges by passing a role to a Glue development endpoint. The attacker creates a dev endpoint with an arbitrary role attached, then accesses those credentials through the endpoint.",
+ provider="aws",
+ cypher="""
+ CALL apoc.create.vNode(['PrivilegeEscalation'], {
+ id: 'effective-administrator-glue',
+ check_title: 'Privilege Escalation',
+ name: 'Effective Administrator (Glue)',
+ status: 'FAIL',
+ severity: 'critical'
+ })
+ YIELD node AS escalation_outcome
+
+ WITH escalation_outcome
+
+ // Find principals in the account
+ MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
+
+ // Principal can assume roles (up to 2 hops)
+ OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
+ WITH escalation_outcome, principal, path_principal, path_assume,
+ CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
+
+ // Find iam:PassRole permission
+ MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
+ WHERE passrole_stmt.effect = 'Allow'
+ AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
+
+ // Find Glue CreateDevEndpoint permission
+ MATCH (effective_principal)--(glue_policy:AWSPolicy)--(glue_stmt:AWSPolicyStatement)
+ WHERE glue_stmt.effect = 'Allow'
+ AND any(action IN glue_stmt.action WHERE toLower(action) = 'glue:createdevendpoint' OR action = '*' OR toLower(action) = 'glue:*')
+
+ // Find target role with elevated permissions
+ MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
+ WHERE target_stmt.effect = 'Allow'
+ AND (
+ any(action IN target_stmt.action WHERE action = '*')
+ OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
+ )
+
+ // Deduplicate before creating virtual nodes
+ WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role
+
+ // Create virtual Glue endpoint node (one per unique principal->target pair)
+ CALL apoc.create.vNode(['GlueDevEndpoint'], {
+ name: 'New Dev Endpoint',
+ description: 'Glue endpoint with target role attached',
+ id: effective_principal.arn + '->' + target_role.arn
+ })
+ YIELD node AS glue_endpoint
+
+ CALL apoc.create.vRelationship(effective_principal, 'CREATES_ENDPOINT', {
+ permissions: ['iam:PassRole', 'glue:CreateDevEndpoint'],
+ technique: 'new-passrole'
+ }, glue_endpoint)
+ YIELD rel AS create_rel
+
+ CALL apoc.create.vRelationship(glue_endpoint, 'RUNS_AS', {}, target_role)
+ YIELD rel AS runs_rel
+
+ CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
+ reference: 'https://pathfinding.cloud/paths/glue-001'
+ }, escalation_outcome)
+ YIELD rel AS grants_rel
+
+ // Re-match paths for visualization
+ MATCH path_principal = (aws)--(principal)
+ MATCH path_target = (aws)--(target_role)
+
+ RETURN path_principal, path_target,
+ glue_endpoint, escalation_outcome, create_rel, runs_rel, grants_rel
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-iam-privesc-attach-role-policy-assume-role",
+ name="Privilege Escalation: iam:AttachRolePolicy + sts:AssumeRole",
+ description="Detect principals who can both attach policies to roles AND assume those roles. This two-step attack allows modifying a role's permissions then assuming it to gain elevated access. This is a principal-access escalation path (pathfinding.cloud: iam-014).",
+ provider="aws",
+ cypher="""
+ // Create a virtual escalation outcome node (styled like a finding)
+ CALL apoc.create.vNode(['PrivilegeEscalation'], {
+ id: 'effective-administrator',
+ check_title: 'Privilege Escalation',
+ name: 'Effective Administrator',
+ status: 'FAIL',
+ severity: 'critical'
+ })
+ YIELD node AS admin_outcome
+
+ WITH admin_outcome
+
+ // Find principals in the account
+ MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
+
+ // Find statements granting iam:AttachRolePolicy
+ MATCH path_attach = (principal)--(attach_policy:AWSPolicy)--(stmt_attach:AWSPolicyStatement)
+ WHERE stmt_attach.effect = 'Allow'
+ AND any(action IN stmt_attach.action WHERE
+ toLower(action) = 'iam:attachrolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find statements granting sts:AssumeRole
+ MATCH path_assume = (principal)--(assume_policy:AWSPolicy)--(stmt_assume:AWSPolicyStatement)
+ WHERE stmt_assume.effect = 'Allow'
+ AND any(action IN stmt_assume.action WHERE
+ toLower(action) = 'sts:assumerole'
+ OR toLower(action) = 'sts:*'
+ OR action = '*'
+ )
+
+ // Find target roles that the principal can both modify AND assume
+ MATCH path_target = (aws)--(target_role:AWSRole)
+ WHERE target_role.arn CONTAINS $provider_uid
+ // Can attach policy to this role
+ AND any(resource IN stmt_attach.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+ // Can assume this role
+ AND any(resource IN stmt_assume.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ // Deduplicate before creating virtual relationships
+ WITH DISTINCT admin_outcome, aws, principal, target_role
+
+ // Create virtual relationships showing the attack path
+ CALL apoc.create.vRelationship(principal, 'CAN_MODIFY', {
+ via: 'iam:AttachRolePolicy'
+ }, target_role)
+ YIELD rel AS modify_rel
+
+ CALL apoc.create.vRelationship(target_role, 'LEADS_TO', {
+ technique: 'iam:AttachRolePolicy + sts:AssumeRole',
+ via: 'sts:AssumeRole',
+ reference: 'https://pathfinding.cloud/paths/iam-014'
+ }, admin_outcome)
+ YIELD rel AS escalation_rel
+
+ // Re-match paths for visualization
+ MATCH path_principal = (aws)--(principal)
+ MATCH path_target = (aws)--(target_role)
+
+ UNWIND nodes(path_principal) + nodes(path_target) as n
+ OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
+ WHERE pf.status = 'FAIL'
+
+ RETURN path_principal, path_target,
+ admin_outcome, modify_rel, escalation_rel,
+ collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+ ),
+ AttackPathsQueryDefinition(
+ id="aws-bedrock-privesc-passrole-code-interpreter",
+ name="Privilege Escalation: Bedrock Code Interpreter with PassRole",
+ description="Detect principals that can escalate privileges by passing a role to a Bedrock AgentCore Code Interpreter. The attacker creates a code interpreter with an arbitrary role, then invokes it to execute code with those credentials.",
+ provider="aws",
+ cypher="""
+ CALL apoc.create.vNode(['PrivilegeEscalation'], {
+ id: 'effective-administrator-bedrock',
+ check_title: 'Privilege Escalation',
+ name: 'Effective Administrator (Bedrock)',
+ status: 'FAIL',
+ severity: 'critical'
+ })
+ YIELD node AS escalation_outcome
+
+ WITH escalation_outcome
+
+ // Find principals in the account
+ MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
+
+ // Principal can assume roles (up to 2 hops)
+ OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
+ WITH escalation_outcome, aws, principal, path_principal, path_assume,
+ CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
+
+ // Find iam:PassRole permission
+ MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
+ WHERE passrole_stmt.effect = 'Allow'
+ AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
+
+ // Find Bedrock AgentCore permissions
+ MATCH (effective_principal)--(bedrock_policy:AWSPolicy)--(bedrock_stmt:AWSPolicyStatement)
+ WHERE bedrock_stmt.effect = 'Allow'
+ AND (
+ any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:createcodeinterpreter' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
+ )
+ AND (
+ any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:startsession' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
+ )
+ AND (
+ any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:invoke' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*')
+ )
+
+ // Find target roles with elevated permissions that could be passed
+ MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
+ WHERE target_stmt.effect = 'Allow'
+ AND (
+ any(action IN target_stmt.action WHERE action = '*')
+ OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
+ )
+
+ // Deduplicate per (principal, target_role) pair
+ WITH DISTINCT escalation_outcome, aws, principal, target_role
+
+ // Group by principal, collect target_roles
+ WITH escalation_outcome, aws, principal,
+ collect(DISTINCT target_role) AS target_roles,
+ count(DISTINCT target_role) AS target_count
+
+ // Create single virtual Bedrock node per principal
+ CALL apoc.create.vNode(['BedrockCodeInterpreter'], {
+ name: 'New Code Interpreter',
+ description: toString(target_count) + ' admin role(s) can be passed',
+ id: principal.arn,
+ target_role_count: target_count
+ })
+ YIELD node AS bedrock_agent
+
+ // Connect from principal (not effective_principal) to keep graph connected
+ CALL apoc.create.vRelationship(principal, 'CREATES_INTERPRETER', {
+ permissions: ['iam:PassRole', 'bedrock-agentcore:CreateCodeInterpreter', 'bedrock-agentcore:StartSession', 'bedrock-agentcore:Invoke'],
+ technique: 'new-passrole'
+ }, bedrock_agent)
+ YIELD rel AS create_rel
+
+ // UNWIND target_roles to show which roles can be passed
+ UNWIND target_roles AS target_role
+
+ CALL apoc.create.vRelationship(bedrock_agent, 'PASSES_ROLE', {}, target_role)
+ YIELD rel AS pass_rel
+
+ CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
+ reference: 'https://pathfinding.cloud/paths/bedrock-001'
+ }, escalation_outcome)
+ YIELD rel AS grants_rel
+
+ // Re-match path for visualization
+ MATCH path_principal = (aws)--(principal)
+
+ RETURN path_principal,
+ bedrock_agent, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
+ """,
+ parameters=[],
+ ),
+ ],
+}
+
+_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = {
+ definition.id: definition
+ for definitions in _QUERY_DEFINITIONS.values()
+ for definition in definitions
+}
diff --git a/api/src/backend/api/attack_paths/retryable_session.py b/api/src/backend/api/attack_paths/retryable_session.py
new file mode 100644
index 0000000000..2c70bc6a8e
--- /dev/null
+++ b/api/src/backend/api/attack_paths/retryable_session.py
@@ -0,0 +1,92 @@
+import logging
+
+from collections.abc import Callable
+from typing import Any
+
+import neo4j
+import neo4j.exceptions
+
+logger = logging.getLogger(__name__)
+
+
+class RetryableSession:
+ """
+ Wrapper around `neo4j.Session` that retries `neo4j.exceptions.ServiceUnavailable` errors.
+ """
+
+ def __init__(
+ self,
+ session_factory: Callable[[], neo4j.Session],
+ max_retries: int,
+ ) -> None:
+ self._session_factory = session_factory
+ self._max_retries = max(0, max_retries)
+ self._session = self._session_factory()
+
+ def close(self) -> None:
+ if self._session is not None:
+ self._session.close()
+ self._session = None
+
+ def __enter__(self) -> "RetryableSession":
+ return self
+
+ def __exit__(
+ self, _: Any, __: Any, ___: Any
+ ) -> None: # Unused args: exc_type, exc, exc_tb
+ self.close()
+
+ def run(self, *args: Any, **kwargs: Any) -> Any:
+ return self._call_with_retry("run", *args, **kwargs)
+
+ def write_transaction(self, *args: Any, **kwargs: Any) -> Any:
+ return self._call_with_retry("write_transaction", *args, **kwargs)
+
+ def read_transaction(self, *args: Any, **kwargs: Any) -> Any:
+ return self._call_with_retry("read_transaction", *args, **kwargs)
+
+ def execute_write(self, *args: Any, **kwargs: Any) -> Any:
+ return self._call_with_retry("execute_write", *args, **kwargs)
+
+ def execute_read(self, *args: Any, **kwargs: Any) -> Any:
+ return self._call_with_retry("execute_read", *args, **kwargs)
+
+ def __getattr__(self, item: str) -> Any:
+ return getattr(self._session, item)
+
+ def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
+ attempt = 0
+ last_exc: Exception | None = None
+
+ while attempt <= self._max_retries:
+ try:
+ method = getattr(self._session, method_name)
+ return method(*args, **kwargs)
+
+ except (
+ BrokenPipeError,
+ ConnectionResetError,
+ neo4j.exceptions.ServiceUnavailable,
+ ) as exc: # pragma: no cover - depends on infra
+ last_exc = exc
+ attempt += 1
+
+ if attempt > self._max_retries:
+ raise
+
+ logger.warning(
+ f"Neo4j session {method_name} failed with {type(exc).__name__} ({attempt}/{self._max_retries} attempts). Retrying..."
+ )
+ self._refresh_session()
+
+ raise last_exc if last_exc else RuntimeError("Unexpected retry loop exit")
+
+ def _refresh_session(self) -> None:
+ if self._session is not None:
+ try:
+ self._session.close()
+ except Exception:
+ # Best-effort close; failures just mean we open a new session below
+ pass
+
+ self._session = self._session_factory()
diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py
new file mode 100644
index 0000000000..7418a0302e
--- /dev/null
+++ b/api/src/backend/api/attack_paths/views_helpers.py
@@ -0,0 +1,143 @@
+import logging
+
+from typing import Any
+
+from rest_framework.exceptions import APIException, ValidationError
+
+from api.attack_paths import database as graph_database, AttackPathsQueryDefinition
+from api.models import AttackPathsScan
+from config.custom_logging import BackendLogger
+
+logger = logging.getLogger(BackendLogger.API)
+
+
+def normalize_run_payload(raw_data):
+ if not isinstance(raw_data, dict): # Let the serializer handle this
+ return raw_data
+
+ if "data" in raw_data and isinstance(raw_data.get("data"), dict):
+ data_section = raw_data.get("data") or {}
+ attributes = data_section.get("attributes") or {}
+ payload = {
+ "id": attributes.get("id", data_section.get("id")),
+ "parameters": attributes.get("parameters"),
+ }
+
+ # Remove `None` parameters to allow defaults downstream
+ if payload.get("parameters") is None:
+ payload.pop("parameters")
+ return payload
+
+ return raw_data
+
+
+def prepare_query_parameters(
+ definition: AttackPathsQueryDefinition,
+ provided_parameters: dict[str, Any],
+ provider_uid: str,
+) -> dict[str, Any]:
+ parameters = dict(provided_parameters or {})
+ expected_names = {parameter.name for parameter in definition.parameters}
+ provided_names = set(parameters.keys())
+
+ unexpected = provided_names - expected_names
+ if unexpected:
+ raise ValidationError(
+ {"parameters": f"Unknown parameter(s): {', '.join(sorted(unexpected))}"}
+ )
+
+ missing = expected_names - provided_names
+ if missing:
+ raise ValidationError(
+ {
+ "parameters": f"Missing required parameter(s): {', '.join(sorted(missing))}"
+ }
+ )
+
+ clean_parameters = {
+ "provider_uid": str(provider_uid),
+ }
+
+ for definition_parameter in definition.parameters:
+ raw_value = provided_parameters[definition_parameter.name]
+
+ try:
+ casted_value = definition_parameter.cast(raw_value)
+
+ except (ValueError, TypeError) as exc:
+ raise ValidationError(
+ {
+ "parameters": (
+ f"Invalid value for parameter `{definition_parameter.name}`: {str(exc)}"
+ )
+ }
+ )
+
+ clean_parameters[definition_parameter.name] = casted_value
+
+ return clean_parameters
+
+
+def execute_attack_paths_query(
+ attack_paths_scan: AttackPathsScan,
+ definition: AttackPathsQueryDefinition,
+ parameters: dict[str, Any],
+) -> dict[str, Any]:
+ try:
+ with graph_database.get_session(attack_paths_scan.graph_database) as session:
+ result = session.run(definition.cypher, parameters)
+ return _serialize_graph(result.graph())
+
+ except graph_database.GraphDatabaseQueryException as exc:
+ logger.error(f"Query failed for Attack Paths query `{definition.id}`: {exc}")
+ raise APIException(
+ "Attack Paths query execution failed due to a database error"
+ )
+
+
+def _serialize_graph(graph):
+ nodes = []
+ for node in graph.nodes:
+ nodes.append(
+ {
+ "id": node.element_id,
+ "labels": list(node.labels),
+ "properties": _serialize_properties(node._properties),
+ },
+ )
+
+ relationships = []
+ for relationship in graph.relationships:
+ relationships.append(
+ {
+ "id": relationship.element_id,
+ "label": relationship.type,
+ "source": relationship.start_node.element_id,
+ "target": relationship.end_node.element_id,
+ "properties": _serialize_properties(relationship._properties),
+ },
+ )
+
+ return {
+ "nodes": nodes,
+ "relationships": relationships,
+ }
+
+
+def _serialize_properties(properties: dict[str, Any]) -> dict[str, Any]:
+ """Convert Neo4j property values into JSON-serializable primitives."""
+
+ def _serialize_value(value: Any) -> Any:
+ # Neo4j temporal and spatial values expose `to_native` returning Python primitives
+ if hasattr(value, "to_native") and callable(value.to_native):
+ return _serialize_value(value.to_native())
+
+ if isinstance(value, (list, tuple)):
+ return [_serialize_value(item) for item in value]
+
+ if isinstance(value, dict):
+ return {key: _serialize_value(val) for key, val in value.items()}
+
+ return value
+
+ return {key: _serialize_value(val) for key, val in properties.items()}
diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py
index da39fc23bb..1705ed2e8f 100644
--- a/api/src/backend/api/compliance.py
+++ b/api/src/backend/api/compliance.py
@@ -1,15 +1,99 @@
-from types import MappingProxyType
+from collections.abc import Iterable, Mapping
from api.models import Provider
from prowler.config.config import get_available_compliance_frameworks
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.models import CheckMetadata
-PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE = {}
-PROWLER_CHECKS = {}
AVAILABLE_COMPLIANCE_FRAMEWORKS = {}
+class LazyComplianceTemplate(Mapping):
+ """Lazy-load compliance templates per provider on first access."""
+
+ def __init__(self, provider_types: Iterable[str] | None = None) -> None:
+ if provider_types is None:
+ provider_types = Provider.ProviderChoices.values
+ self._provider_types = tuple(provider_types)
+ self._provider_types_set = set(self._provider_types)
+ self._cache: dict[str, dict] = {}
+
+ def _load_provider(self, provider_type: str) -> dict:
+ if provider_type not in self._provider_types_set:
+ raise KeyError(provider_type)
+ cached = self._cache.get(provider_type)
+ if cached is not None:
+ return cached
+ _ensure_provider_loaded(provider_type)
+ return self._cache[provider_type]
+
+ def __getitem__(self, key: str) -> dict:
+ return self._load_provider(key)
+
+ def __iter__(self):
+ return iter(self._provider_types)
+
+ def __len__(self) -> int:
+ return len(self._provider_types)
+
+ def __contains__(self, key: object) -> bool:
+ return key in self._provider_types_set
+
+ def get(self, key: str, default=None):
+ if key not in self._provider_types_set:
+ return default
+ return self._load_provider(key)
+
+ def __repr__(self) -> str: # pragma: no cover - debugging helper
+ loaded = ", ".join(sorted(self._cache))
+ return f"{self.__class__.__name__}(loaded=[{loaded}])"
+
+
+class LazyChecksMapping(Mapping):
+ """Lazy-load checks mapping per provider on first access."""
+
+ def __init__(self, provider_types: Iterable[str] | None = None) -> None:
+ if provider_types is None:
+ provider_types = Provider.ProviderChoices.values
+ self._provider_types = tuple(provider_types)
+ self._provider_types_set = set(self._provider_types)
+ self._cache: dict[str, dict] = {}
+
+ def _load_provider(self, provider_type: str) -> dict:
+ if provider_type not in self._provider_types_set:
+ raise KeyError(provider_type)
+ cached = self._cache.get(provider_type)
+ if cached is not None:
+ return cached
+ _ensure_provider_loaded(provider_type)
+ return self._cache[provider_type]
+
+ def __getitem__(self, key: str) -> dict:
+ return self._load_provider(key)
+
+ def __iter__(self):
+ return iter(self._provider_types)
+
+ def __len__(self) -> int:
+ return len(self._provider_types)
+
+ def __contains__(self, key: object) -> bool:
+ return key in self._provider_types_set
+
+ def get(self, key: str, default=None):
+ if key not in self._provider_types_set:
+ return default
+ return self._load_provider(key)
+
+ def __repr__(self) -> str: # pragma: no cover - debugging helper
+ loaded = ", ".join(sorted(self._cache))
+ return f"{self.__class__.__name__}(loaded=[{loaded}])"
+
+
+PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE = LazyComplianceTemplate()
+PROWLER_CHECKS = LazyChecksMapping()
+
+
def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]:
"""
Retrieve and cache the list of available compliance frameworks for a specific cloud provider.
@@ -70,28 +154,35 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) ->
return Compliance.get_bulk(provider_type)
-def load_prowler_compliance():
- """
- Load and initialize the Prowler compliance data and checks for all provider types.
-
- This function retrieves compliance data for all supported provider types,
- generates a compliance overview template, and populates the global variables
- `PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE` and `PROWLER_CHECKS` with read-only mappings
- of the compliance templates and checks, respectively.
- """
- global PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE
- global PROWLER_CHECKS
-
- prowler_compliance = {
- provider_type: get_prowler_provider_compliance(provider_type)
- for provider_type in Provider.ProviderChoices.values
- }
- template = generate_compliance_overview_template(prowler_compliance)
- PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE = MappingProxyType(template)
- PROWLER_CHECKS = MappingProxyType(load_prowler_checks(prowler_compliance))
+def _load_provider_assets(provider_type: Provider.ProviderChoices) -> tuple[dict, dict]:
+ prowler_compliance = {provider_type: get_prowler_provider_compliance(provider_type)}
+ template = generate_compliance_overview_template(
+ prowler_compliance, provider_types=[provider_type]
+ )
+ checks = load_prowler_checks(prowler_compliance, provider_types=[provider_type])
+ return template.get(provider_type, {}), checks.get(provider_type, {})
-def load_prowler_checks(prowler_compliance):
+def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None:
+ if (
+ provider_type in PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE._cache
+ and provider_type in PROWLER_CHECKS._cache
+ ):
+ return
+ template_cached = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE._cache.get(provider_type)
+ checks_cached = PROWLER_CHECKS._cache.get(provider_type)
+ if template_cached is not None and checks_cached is not None:
+ return
+ template, checks = _load_provider_assets(provider_type)
+ if template_cached is None:
+ PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE._cache[provider_type] = template
+ if checks_cached is None:
+ PROWLER_CHECKS._cache[provider_type] = checks
+
+
+def load_prowler_checks(
+ prowler_compliance, provider_types: Iterable[str] | None = None
+):
"""
Generate a mapping of checks to the compliance frameworks that include them.
@@ -100,21 +191,25 @@ def load_prowler_checks(prowler_compliance):
of compliance names that include that check.
Args:
- prowler_compliance (dict): The compliance data for all provider types,
+ prowler_compliance (dict): The compliance data for provider types,
as returned by `get_prowler_provider_compliance`.
+ provider_types (Iterable[str] | None): Optional subset of provider types to
+ process. Defaults to all providers.
Returns:
dict: A nested dictionary where the first-level keys are provider types,
and the values are dictionaries mapping check IDs to sets of compliance names.
"""
checks = {}
- for provider_type in Provider.ProviderChoices.values:
+ if provider_types is None:
+ provider_types = Provider.ProviderChoices.values
+ for provider_type in provider_types:
checks[provider_type] = {
check_id: set() for check_id in get_prowler_provider_checks(provider_type)
}
- for compliance_name, compliance_data in prowler_compliance[
- provider_type
- ].items():
+ for compliance_name, compliance_data in prowler_compliance.get(
+ provider_type, {}
+ ).items():
for requirement in compliance_data.Requirements:
for check in requirement.Checks:
try:
@@ -163,7 +258,9 @@ def generate_scan_compliance(
] += 1
-def generate_compliance_overview_template(prowler_compliance: dict):
+def generate_compliance_overview_template(
+ prowler_compliance: dict, provider_types: Iterable[str] | None = None
+):
"""
Generate a compliance overview template for all provider types.
@@ -173,17 +270,21 @@ def generate_compliance_overview_template(prowler_compliance: dict):
counts for requirements status.
Args:
- prowler_compliance (dict): The compliance data for all provider types,
+ prowler_compliance (dict): The compliance data for provider types,
as returned by `get_prowler_provider_compliance`.
+ provider_types (Iterable[str] | None): Optional subset of provider types to
+ process. Defaults to all providers.
Returns:
dict: A nested dictionary representing the compliance overview template,
structured by provider type and compliance framework.
"""
template = {}
- for provider_type in Provider.ProviderChoices.values:
+ if provider_types is None:
+ provider_types = Provider.ProviderChoices.values
+ for provider_type in provider_types:
provider_compliance = template.setdefault(provider_type, {})
- compliance_data_dict = prowler_compliance[provider_type]
+ compliance_data_dict = prowler_compliance.get(provider_type, {})
for compliance_name, compliance_data in compliance_data_dict.items():
compliance_requirements = {}
diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py
index c6fcaeb43a..b719d4b736 100644
--- a/api/src/backend/api/db_utils.py
+++ b/api/src/backend/api/db_utils.py
@@ -12,7 +12,6 @@ from django.contrib.auth.models import BaseUserManager
from django.db import (
DEFAULT_DB_ALIAS,
OperationalError,
- connection,
connections,
models,
transaction,
@@ -450,7 +449,7 @@ def create_index_on_partitions(
all_partitions=True
)
"""
- with connection.cursor() as cursor:
+ with schema_editor.connection.cursor() as cursor:
cursor.execute(
"""
SELECT inhrelid::regclass::text
@@ -462,6 +461,7 @@ def create_index_on_partitions(
partitions = [row[0] for row in cursor.fetchall()]
where_sql = f" WHERE {where}" if where else ""
+ conn = schema_editor.connection
for partition in partitions:
if _should_create_index_on_partition(partition, all_partitions):
idx_name = f"{partition.replace('.', '_')}_{index_name}"
@@ -470,7 +470,12 @@ def create_index_on_partitions(
f"ON {partition} USING {method} ({columns})"
f"{where_sql};"
)
- schema_editor.execute(sql)
+ old_autocommit = conn.connection.autocommit
+ conn.connection.autocommit = True
+ try:
+ schema_editor.execute(sql)
+ finally:
+ conn.connection.autocommit = old_autocommit
def drop_index_on_partitions(
@@ -486,7 +491,8 @@ def drop_index_on_partitions(
parent_table: The name of the root table (e.g. "findings").
index_name: The same short name used when creating them.
"""
- with connection.cursor() as cursor:
+ conn = schema_editor.connection
+ with conn.cursor() as cursor:
cursor.execute(
"""
SELECT inhrelid::regclass::text
@@ -500,7 +506,12 @@ def drop_index_on_partitions(
for partition in partitions:
idx_name = f"{partition.replace('.', '_')}_{index_name}"
sql = f"DROP INDEX CONCURRENTLY IF EXISTS {idx_name};"
- schema_editor.execute(sql)
+ old_autocommit = conn.connection.autocommit
+ conn.connection.autocommit = True
+ try:
+ schema_editor.execute(sql)
+ finally:
+ conn.connection.autocommit = old_autocommit
def generate_api_key_prefix():
diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py
index 73af170f94..78f8c64c7d 100644
--- a/api/src/backend/api/exceptions.py
+++ b/api/src/backend/api/exceptions.py
@@ -107,3 +107,105 @@ class ConflictException(APIException):
error_detail["source"] = {"pointer": pointer}
super().__init__(detail=[error_detail])
+
+
+# Upstream Provider Errors (for external API calls like CloudTrail)
+# These indicate issues with the provider, not with the user's API authentication
+
+
+class UpstreamAuthenticationError(APIException):
+ """Provider credentials are invalid or expired (502 Bad Gateway).
+
+ Used when AWS/Azure/GCP credentials fail to authenticate with the upstream
+ provider. This is NOT the user's API authentication failing.
+ """
+
+ status_code = status.HTTP_502_BAD_GATEWAY
+ default_detail = (
+ "Provider credentials are invalid or expired. Please reconnect the provider."
+ )
+ default_code = "upstream_auth_failed"
+
+ def __init__(self, detail=None):
+ super().__init__(
+ detail=[
+ {
+ "detail": detail or self.default_detail,
+ "status": str(self.status_code),
+ "code": self.default_code,
+ }
+ ]
+ )
+
+
+class UpstreamAccessDeniedError(APIException):
+ """Provider credentials lack required permissions (502 Bad Gateway).
+
+ Used when credentials are valid but don't have the IAM permissions
+ needed for the requested operation (e.g., cloudtrail:LookupEvents).
+ This is 502 (not 403) because it's an upstream/gateway error - the USER
+ authenticated fine, but the PROVIDER's credentials are misconfigured.
+ """
+
+ status_code = status.HTTP_502_BAD_GATEWAY
+ default_detail = (
+ "Access denied. The provider credentials do not have the required permissions."
+ )
+ default_code = "upstream_access_denied"
+
+ def __init__(self, detail=None):
+ super().__init__(
+ detail=[
+ {
+ "detail": detail or self.default_detail,
+ "status": str(self.status_code),
+ "code": self.default_code,
+ }
+ ]
+ )
+
+
+class UpstreamServiceUnavailableError(APIException):
+ """Provider service is unavailable (503 Service Unavailable).
+
+ Used when the upstream provider API returns an error or is unreachable.
+ """
+
+ status_code = status.HTTP_503_SERVICE_UNAVAILABLE
+ default_detail = "Unable to communicate with the provider. Please try again later."
+ default_code = "service_unavailable"
+
+ def __init__(self, detail=None):
+ super().__init__(
+ detail=[
+ {
+ "detail": detail or self.default_detail,
+ "status": str(self.status_code),
+ "code": self.default_code,
+ }
+ ]
+ )
+
+
+class UpstreamInternalError(APIException):
+ """Unexpected error communicating with provider (500 Internal Server Error).
+
+ Used as a catch-all for unexpected errors during provider communication.
+ """
+
+ status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
+ default_detail = (
+ "An unexpected error occurred while communicating with the provider."
+ )
+ default_code = "internal_error"
+
+ def __init__(self, detail=None):
+ super().__init__(
+ detail=[
+ {
+ "detail": detail or self.default_detail,
+ "status": str(self.status_code),
+ "code": self.default_code,
+ }
+ ]
+ )
diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py
index fdb2282e12..bf34950156 100644
--- a/api/src/backend/api/filters.py
+++ b/api/src/backend/api/filters.py
@@ -29,6 +29,7 @@ from api.models import (
Finding,
Integration,
Invitation,
+ AttackPathsScan,
LighthouseProviderConfiguration,
LighthouseProviderModels,
Membership,
@@ -45,6 +46,7 @@ from api.models import (
Role,
Scan,
ScanCategorySummary,
+ ScanGroupSummary,
ScanSummary,
SeverityChoices,
StateChoices,
@@ -214,6 +216,9 @@ class CommonFindingFilters(FilterSet):
category = CharFilter(method="filter_category")
category__in = CharInFilter(field_name="categories", lookup_expr="overlap")
+ resource_groups = CharFilter(field_name="resource_groups", lookup_expr="exact")
+ resource_groups__in = CharInFilter(field_name="resource_groups", lookup_expr="in")
+
# Temporarily disabled until we implement tag filtering in the UI
# resource_tag_key = CharFilter(field_name="resources__tags__key")
# resource_tag_key__in = CharInFilter(
@@ -392,6 +397,23 @@ class ScanFilter(ProviderRelationshipFilterSet):
}
+class AttackPathsScanFilter(ProviderRelationshipFilterSet):
+ inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
+ completed_at = DateFilter(field_name="completed_at", lookup_expr="date")
+ started_at = DateFilter(field_name="started_at", lookup_expr="date")
+ state = ChoiceFilter(choices=StateChoices.choices)
+ state__in = ChoiceInFilter(
+ field_name="state", choices=StateChoices.choices, lookup_expr="in"
+ )
+
+ class Meta:
+ model = AttackPathsScan
+ fields = {
+ "provider": ["exact", "in"],
+ "scan": ["exact", "in"],
+ }
+
+
class TaskFilter(FilterSet):
name = CharFilter(field_name="task_runner_task__task_name", lookup_expr="exact")
name__icontains = CharFilter(
@@ -431,6 +453,8 @@ class ResourceTagFilter(FilterSet):
class ResourceFilter(ProviderRelationshipFilterSet):
+ provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
+ provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
tag_key = CharFilter(method="filter_tag_key")
tag_value = CharFilter(method="filter_tag_value")
tag = CharFilter(method="filter_tag")
@@ -439,6 +463,8 @@ class ResourceFilter(ProviderRelationshipFilterSet):
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
scan = UUIDFilter(field_name="provider__scan", lookup_expr="exact")
scan__in = UUIDInFilter(field_name="provider__scan", lookup_expr="in")
+ groups = CharFilter(method="filter_groups")
+ groups__in = CharInFilter(field_name="groups", lookup_expr="overlap")
class Meta:
model = Resource
@@ -453,6 +479,9 @@ class ResourceFilter(ProviderRelationshipFilterSet):
"updated_at": ["gte", "lte"],
}
+ def filter_groups(self, queryset, name, value):
+ return queryset.filter(groups__contains=[value])
+
def filter_queryset(self, queryset):
if not (self.data.get("scan") or self.data.get("scan__in")) and not (
self.data.get("updated_at")
@@ -513,10 +542,14 @@ class ResourceFilter(ProviderRelationshipFilterSet):
class LatestResourceFilter(ProviderRelationshipFilterSet):
+ provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
+ provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
tag_key = CharFilter(method="filter_tag_key")
tag_value = CharFilter(method="filter_tag_value")
tag = CharFilter(method="filter_tag")
tags = CharFilter(method="filter_tag")
+ groups = CharFilter(method="filter_groups")
+ groups__in = CharInFilter(field_name="groups", lookup_expr="overlap")
class Meta:
model = Resource
@@ -529,6 +562,9 @@ class LatestResourceFilter(ProviderRelationshipFilterSet):
"type": ["exact", "icontains", "in"],
}
+ def filter_groups(self, queryset, name, value):
+ return queryset.filter(groups__contains=[value])
+
def filter_tag_key(self, queryset, name, value):
return queryset.filter(Q(tags__key=value) | Q(tags__key__icontains=value))
@@ -1154,6 +1190,26 @@ class CategoryOverviewFilter(BaseScanProviderFilter):
class Meta(BaseScanProviderFilter.Meta):
model = ScanCategorySummary
+ fields = {}
+
+
+class ResourceGroupOverviewFilter(FilterSet):
+ provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
+ provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
+ provider_type = ChoiceFilter(
+ field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
+ )
+ provider_type__in = ChoiceInFilter(
+ field_name="scan__provider__provider",
+ choices=Provider.ProviderChoices.choices,
+ lookup_expr="in",
+ )
+ resource_group = CharFilter(field_name="resource_group", lookup_expr="exact")
+ resource_group__in = CharInFilter(field_name="resource_group", lookup_expr="in")
+
+ class Meta:
+ model = ScanGroupSummary
+ fields = {}
class ComplianceWatchlistFilter(BaseProviderFilter):
diff --git a/api/src/backend/api/fixtures/dev/8_dev_attack_paths_scans.json b/api/src/backend/api/fixtures/dev/8_dev_attack_paths_scans.json
new file mode 100644
index 0000000000..fdf310458a
--- /dev/null
+++ b/api/src/backend/api/fixtures/dev/8_dev_attack_paths_scans.json
@@ -0,0 +1,41 @@
+[
+ {
+ "model": "api.attackpathsscan",
+ "pk": "a7f0f6de-6f8e-4b3a-8cbe-3f6dd9012345",
+ "fields": {
+ "tenant": "12646005-9067-4d2a-a098-8bb378604362",
+ "provider": "b85601a8-4b45-4194-8135-03fb980ef428",
+ "scan": "01920573-aa9c-73c9-bcda-f2e35c9b19d2",
+ "state": "completed",
+ "progress": 100,
+ "update_tag": 1693586667,
+ "graph_database": "db-a7f0f6de-6f8e-4b3a-8cbe-3f6dd9012345",
+ "is_graph_database_deleted": false,
+ "task": null,
+ "inserted_at": "2024-09-01T17:24:37Z",
+ "updated_at": "2024-09-01T17:44:37Z",
+ "started_at": "2024-09-01T17:34:37Z",
+ "completed_at": "2024-09-01T17:44:37Z",
+ "duration": 269,
+ "ingestion_exceptions": {}
+ }
+ },
+ {
+ "model": "api.attackpathsscan",
+ "pk": "4a2fb2af-8a60-4d7d-9cae-4ca65e098765",
+ "fields": {
+ "tenant": "12646005-9067-4d2a-a098-8bb378604362",
+ "provider": "15fce1fa-ecaa-433f-a9dc-62553f3a2555",
+ "scan": "01929f3b-ed2e-7623-ad63-7c37cd37828f",
+ "state": "executing",
+ "progress": 48,
+ "update_tag": 1697625000,
+ "graph_database": "db-4a2fb2af-8a60-4d7d-9cae-4ca65e098765",
+ "is_graph_database_deleted": false,
+ "task": null,
+ "inserted_at": "2024-10-18T10:55:57Z",
+ "updated_at": "2024-10-18T10:56:15Z",
+ "started_at": "2024-10-18T10:56:05Z"
+ }
+ }
+]
diff --git a/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py b/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py
new file mode 100644
index 0000000000..932a2a6c85
--- /dev/null
+++ b/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py
@@ -0,0 +1,126 @@
+import uuid
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import api.db_utils
+import api.rls
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0067_tenant_compliance_summary"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="finding",
+ name="resource_groups",
+ field=models.TextField(
+ blank=True,
+ help_text="Resource group from check metadata for efficient filtering",
+ null=True,
+ ),
+ ),
+ migrations.CreateModel(
+ name="ScanGroupSummary",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ (
+ "tenant",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="api.tenant",
+ ),
+ ),
+ (
+ "inserted_at",
+ models.DateTimeField(auto_now_add=True),
+ ),
+ (
+ "scan",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="resource_group_summaries",
+ related_query_name="resource_group_summary",
+ to="api.scan",
+ ),
+ ),
+ (
+ "resource_group",
+ models.CharField(max_length=50),
+ ),
+ (
+ "severity",
+ api.db_utils.SeverityEnumField(
+ choices=[
+ ("critical", "Critical"),
+ ("high", "High"),
+ ("medium", "Medium"),
+ ("low", "Low"),
+ ("informational", "Informational"),
+ ],
+ ),
+ ),
+ (
+ "total_findings",
+ models.IntegerField(
+ default=0, help_text="Non-muted findings (PASS + FAIL)"
+ ),
+ ),
+ (
+ "failed_findings",
+ models.IntegerField(
+ default=0,
+ help_text="Non-muted FAIL findings (subset of total_findings)",
+ ),
+ ),
+ (
+ "new_failed_findings",
+ models.IntegerField(
+ default=0,
+ help_text="Non-muted FAIL with delta='new' (subset of failed_findings)",
+ ),
+ ),
+ (
+ "resources_count",
+ models.IntegerField(
+ default=0, help_text="Count of distinct resource_uid values"
+ ),
+ ),
+ ],
+ options={
+ "db_table": "scan_resource_group_summaries",
+ "abstract": False,
+ },
+ ),
+ migrations.AddIndex(
+ model_name="scangroupsummary",
+ index=models.Index(
+ fields=["tenant_id", "scan"], name="srgs_tenant_scan_idx"
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="scangroupsummary",
+ constraint=models.UniqueConstraint(
+ fields=("tenant_id", "scan_id", "resource_group", "severity"),
+ name="unique_resource_group_severity_per_scan",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="scangroupsummary",
+ constraint=api.rls.RowLevelSecurityConstraint(
+ field="tenant_id",
+ name="rls_on_scangroupsummary",
+ statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
+ ),
+ ),
+ ]
diff --git a/api/src/backend/api/migrations/0069_resource_resource_group.py b/api/src/backend/api/migrations/0069_resource_resource_group.py
new file mode 100644
index 0000000000..14a26995c2
--- /dev/null
+++ b/api/src/backend/api/migrations/0069_resource_resource_group.py
@@ -0,0 +1,21 @@
+from django.contrib.postgres.fields import ArrayField
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0068_finding_resource_group_scangroupsummary"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="resource",
+ name="groups",
+ field=ArrayField(
+ models.CharField(max_length=100),
+ blank=True,
+ help_text="Groups for categorization (e.g., compute, storage, IAM)",
+ null=True,
+ ),
+ ),
+ ]
diff --git a/api/src/backend/api/migrations/0070_attack_paths_scan.py b/api/src/backend/api/migrations/0070_attack_paths_scan.py
new file mode 100644
index 0000000000..3e63d3353b
--- /dev/null
+++ b/api/src/backend/api/migrations/0070_attack_paths_scan.py
@@ -0,0 +1,154 @@
+# Generated by Django 5.1.13 on 2025-11-06 16:20
+
+import django.db.models.deletion
+
+from django.db import migrations, models
+from uuid6 import uuid7
+
+import api.rls
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0069_resource_resource_group"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AttackPathsScan",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid7,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("inserted_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "state",
+ api.db_utils.StateEnumField(
+ choices=[
+ ("available", "Available"),
+ ("scheduled", "Scheduled"),
+ ("executing", "Executing"),
+ ("completed", "Completed"),
+ ("failed", "Failed"),
+ ("cancelled", "Cancelled"),
+ ],
+ default="available",
+ ),
+ ),
+ ("progress", models.IntegerField(default=0)),
+ ("started_at", models.DateTimeField(blank=True, null=True)),
+ ("completed_at", models.DateTimeField(blank=True, null=True)),
+ (
+ "duration",
+ models.IntegerField(
+ blank=True, help_text="Duration in seconds", null=True
+ ),
+ ),
+ (
+ "update_tag",
+ models.BigIntegerField(
+ blank=True,
+ help_text="Cartography update tag (epoch)",
+ null=True,
+ ),
+ ),
+ (
+ "graph_database",
+ models.CharField(blank=True, max_length=63, null=True),
+ ),
+ (
+ "is_graph_database_deleted",
+ models.BooleanField(default=False),
+ ),
+ (
+ "ingestion_exceptions",
+ models.JSONField(blank=True, default=dict, null=True),
+ ),
+ (
+ "provider",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="attack_paths_scans",
+ related_query_name="attack_paths_scan",
+ to="api.provider",
+ ),
+ ),
+ (
+ "scan",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="attack_paths_scans",
+ related_query_name="attack_paths_scan",
+ to="api.scan",
+ ),
+ ),
+ (
+ "task",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="attack_paths_scans",
+ related_query_name="attack_paths_scan",
+ to="api.task",
+ ),
+ ),
+ (
+ "tenant",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
+ ),
+ ),
+ ],
+ options={
+ "db_table": "attack_paths_scans",
+ "abstract": False,
+ "indexes": [
+ models.Index(
+ fields=["tenant_id", "provider_id", "-inserted_at"],
+ name="aps_prov_ins_desc_idx",
+ ),
+ models.Index(
+ fields=["tenant_id", "state", "-inserted_at"],
+ name="aps_state_ins_desc_idx",
+ ),
+ models.Index(
+ fields=["tenant_id", "scan_id"],
+ name="aps_scan_lookup_idx",
+ ),
+ models.Index(
+ fields=["tenant_id", "provider_id"],
+ name="aps_active_graph_idx",
+ include=["graph_database", "id"],
+ condition=models.Q(("is_graph_database_deleted", False)),
+ ),
+ models.Index(
+ fields=["tenant_id", "provider_id", "-completed_at"],
+ name="aps_completed_graph_idx",
+ include=["graph_database", "id"],
+ condition=models.Q(
+ ("state", "completed"),
+ ("is_graph_database_deleted", False),
+ ),
+ ),
+ ],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="attackpathsscan",
+ constraint=api.rls.RowLevelSecurityConstraint(
+ "tenant_id",
+ name="rls_on_attackpathsscan",
+ statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
+ ),
+ ),
+ ]
diff --git a/api/src/backend/api/migrations/0071_drop_partitioned_indexes.py b/api/src/backend/api/migrations/0071_drop_partitioned_indexes.py
new file mode 100644
index 0000000000..e1b1e192ad
--- /dev/null
+++ b/api/src/backend/api/migrations/0071_drop_partitioned_indexes.py
@@ -0,0 +1,41 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ """
+ Drop unused indexes on partitioned tables (findings, resource_finding_mappings).
+
+ NOTE: RemoveIndexConcurrently cannot be used on partitioned tables in PostgreSQL.
+ Standard RemoveIndex drops the parent index, which cascades to all partitions.
+ """
+
+ dependencies = [
+ ("api", "0070_attack_paths_scan"),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name="finding",
+ name="gin_findings_search_idx",
+ ),
+ migrations.RemoveIndex(
+ model_name="finding",
+ name="gin_find_service_idx",
+ ),
+ migrations.RemoveIndex(
+ model_name="finding",
+ name="gin_find_region_idx",
+ ),
+ migrations.RemoveIndex(
+ model_name="finding",
+ name="gin_find_rtype_idx",
+ ),
+ migrations.RemoveIndex(
+ model_name="finding",
+ name="find_delta_new_idx",
+ ),
+ migrations.RemoveIndex(
+ model_name="resourcefindingmapping",
+ name="rfm_tenant_finding_idx",
+ ),
+ ]
diff --git a/api/src/backend/api/migrations/0072_drop_unused_indexes.py b/api/src/backend/api/migrations/0072_drop_unused_indexes.py
new file mode 100644
index 0000000000..81f1f69c0d
--- /dev/null
+++ b/api/src/backend/api/migrations/0072_drop_unused_indexes.py
@@ -0,0 +1,91 @@
+"""
+Drop unused indexes on non-partitioned tables.
+
+These tables are not partitioned, so RemoveIndexConcurrently can be used safely.
+"""
+
+from uuid import uuid4
+
+from django.contrib.postgres.operations import RemoveIndexConcurrently
+from django.db import migrations, models
+
+
+def drop_resource_scan_summary_resource_id_index(apps, schema_editor):
+ with schema_editor.connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT idx_ns.nspname, idx.relname
+ FROM pg_class tbl
+ JOIN pg_namespace tbl_ns ON tbl_ns.oid = tbl.relnamespace
+ JOIN pg_index i ON i.indrelid = tbl.oid
+ JOIN pg_class idx ON idx.oid = i.indexrelid
+ JOIN pg_namespace idx_ns ON idx_ns.oid = idx.relnamespace
+ JOIN pg_attribute a
+ ON a.attrelid = tbl.oid
+ AND a.attnum = (i.indkey::int[])[0]
+ WHERE tbl_ns.nspname = ANY (current_schemas(false))
+ AND tbl.relname = %s
+ AND i.indnatts = 1
+ AND a.attname = %s
+ """,
+ ["resource_scan_summaries", "resource_id"],
+ )
+ row = cursor.fetchone()
+
+ if not row:
+ return
+
+ schema_name, index_name = row
+ quote_name = schema_editor.connection.ops.quote_name
+ qualified_name = f"{quote_name(schema_name)}.{quote_name(index_name)}"
+ schema_editor.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified_name};")
+
+
+class Migration(migrations.Migration):
+ atomic = False
+
+ dependencies = [
+ ("api", "0071_drop_partitioned_indexes"),
+ ]
+
+ operations = [
+ RemoveIndexConcurrently(
+ model_name="resource",
+ name="gin_resources_search_idx",
+ ),
+ RemoveIndexConcurrently(
+ model_name="resourcetag",
+ name="gin_resource_tags_search_idx",
+ ),
+ RemoveIndexConcurrently(
+ model_name="scansummary",
+ name="ss_tenant_scan_service_idx",
+ ),
+ RemoveIndexConcurrently(
+ model_name="complianceoverview",
+ name="comp_ov_cp_id_idx",
+ ),
+ RemoveIndexConcurrently(
+ model_name="complianceoverview",
+ name="comp_ov_req_fail_idx",
+ ),
+ RemoveIndexConcurrently(
+ model_name="complianceoverview",
+ name="comp_ov_cp_id_req_fail_idx",
+ ),
+ migrations.SeparateDatabaseAndState(
+ database_operations=[
+ migrations.RunPython(
+ drop_resource_scan_summary_resource_id_index,
+ reverse_code=migrations.RunPython.noop,
+ ),
+ ],
+ state_operations=[
+ migrations.AlterField(
+ model_name="resourcescansummary",
+ name="resource_id",
+ field=models.UUIDField(default=uuid4),
+ ),
+ ],
+ ),
+ ]
diff --git a/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py b/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py
new file mode 100644
index 0000000000..671fdf5ef6
--- /dev/null
+++ b/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py
@@ -0,0 +1,31 @@
+from functools import partial
+
+from django.db import migrations
+
+from api.db_utils import create_index_on_partitions, drop_index_on_partitions
+
+
+class Migration(migrations.Migration):
+ atomic = False
+
+ dependencies = [
+ ("api", "0072_drop_unused_indexes"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ partial(
+ create_index_on_partitions,
+ parent_table="findings",
+ index_name="find_tenant_scan_fail_new_idx",
+ columns="tenant_id, scan_id",
+ where="status = 'FAIL' AND delta = 'new'",
+ all_partitions=True,
+ ),
+ reverse_code=partial(
+ drop_index_on_partitions,
+ parent_table="findings",
+ index_name="find_tenant_scan_fail_new_idx",
+ ),
+ )
+ ]
diff --git a/api/src/backend/api/migrations/0074_findings_fail_new_index_parent.py b/api/src/backend/api/migrations/0074_findings_fail_new_index_parent.py
new file mode 100644
index 0000000000..a889ba0ed4
--- /dev/null
+++ b/api/src/backend/api/migrations/0074_findings_fail_new_index_parent.py
@@ -0,0 +1,54 @@
+from django.db import migrations, models
+
+INDEX_NAME = "find_tenant_scan_fail_new_idx"
+PARENT_TABLE = "findings"
+
+
+def create_parent_and_attach(apps, schema_editor):
+ with schema_editor.connection.cursor() as cursor:
+ cursor.execute(
+ f"CREATE INDEX {INDEX_NAME} ON ONLY {PARENT_TABLE} "
+ f"USING btree (tenant_id, scan_id) "
+ f"WHERE status = 'FAIL' AND delta = 'new'"
+ )
+ cursor.execute(
+ "SELECT inhrelid::regclass::text "
+ "FROM pg_inherits "
+ "WHERE inhparent = %s::regclass",
+ [PARENT_TABLE],
+ )
+ for (partition,) in cursor.fetchall():
+ child_idx = f"{partition.replace('.', '_')}_{INDEX_NAME}"
+ cursor.execute(f"ALTER INDEX {INDEX_NAME} ATTACH PARTITION {child_idx}")
+
+
+def drop_parent_index(apps, schema_editor):
+ with schema_editor.connection.cursor() as cursor:
+ cursor.execute(f"DROP INDEX IF EXISTS {INDEX_NAME}")
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0073_findings_fail_new_index_partitions"),
+ ]
+
+ operations = [
+ migrations.SeparateDatabaseAndState(
+ state_operations=[
+ migrations.AddIndex(
+ model_name="finding",
+ index=models.Index(
+ condition=models.Q(status="FAIL", delta="new"),
+ fields=["tenant_id", "scan_id"],
+ name=INDEX_NAME,
+ ),
+ ),
+ ],
+ database_operations=[
+ migrations.RunPython(
+ create_parent_and_attach,
+ reverse_code=drop_parent_index,
+ ),
+ ],
+ ),
+ ]
diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py
index aa568bd17c..fdd52cb8d1 100644
--- a/api/src/backend/api/models.py
+++ b/api/src/backend/api/models.py
@@ -12,7 +12,6 @@ from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
-from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError
@@ -626,6 +625,101 @@ class Scan(RowLevelSecurityProtectedModel):
resource_name = "scans"
+class AttackPathsScan(RowLevelSecurityProtectedModel):
+ objects = ActiveProviderManager()
+ all_objects = models.Manager()
+
+ id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
+ inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
+ updated_at = models.DateTimeField(auto_now=True, editable=False)
+
+ state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE)
+ progress = models.IntegerField(default=0)
+
+ # Timing
+ started_at = models.DateTimeField(null=True, blank=True)
+ completed_at = models.DateTimeField(null=True, blank=True)
+ duration = models.IntegerField(
+ null=True, blank=True, help_text="Duration in seconds"
+ )
+
+ # Relationship to the provider and optional prowler Scan and celery Task
+ provider = models.ForeignKey(
+ "Provider",
+ on_delete=models.CASCADE,
+ related_name="attack_paths_scans",
+ related_query_name="attack_paths_scan",
+ )
+ scan = models.ForeignKey(
+ "Scan",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="attack_paths_scans",
+ related_query_name="attack_paths_scan",
+ )
+ task = models.ForeignKey(
+ "Task",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="attack_paths_scans",
+ related_query_name="attack_paths_scan",
+ )
+
+ # Cartography specific metadata
+ update_tag = models.BigIntegerField(
+ null=True, blank=True, help_text="Cartography update tag (epoch)"
+ )
+ graph_database = models.CharField(max_length=63, null=True, blank=True)
+ is_graph_database_deleted = models.BooleanField(default=False)
+ ingestion_exceptions = models.JSONField(default=dict, null=True, blank=True)
+
+ class Meta(RowLevelSecurityProtectedModel.Meta):
+ db_table = "attack_paths_scans"
+
+ constraints = [
+ RowLevelSecurityConstraint(
+ field="tenant_id",
+ name="rls_on_%(class)s",
+ statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
+ ),
+ ]
+
+ indexes = [
+ models.Index(
+ fields=["tenant_id", "provider_id", "-inserted_at"],
+ name="aps_prov_ins_desc_idx",
+ ),
+ models.Index(
+ fields=["tenant_id", "state", "-inserted_at"],
+ name="aps_state_ins_desc_idx",
+ ),
+ models.Index(
+ fields=["tenant_id", "scan_id"],
+ name="aps_scan_lookup_idx",
+ ),
+ models.Index(
+ fields=["tenant_id", "provider_id"],
+ name="aps_active_graph_idx",
+ include=["graph_database", "id"],
+ condition=Q(is_graph_database_deleted=False),
+ ),
+ models.Index(
+ fields=["tenant_id", "provider_id", "-completed_at"],
+ name="aps_completed_graph_idx",
+ include=["graph_database", "id"],
+ condition=Q(
+ state=StateChoices.COMPLETED,
+ is_graph_database_deleted=False,
+ ),
+ ),
+ ]
+
+ class JSONAPIMeta:
+ resource_name = "attack-paths-scans"
+
+
class ResourceTag(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
@@ -646,10 +740,6 @@ class ResourceTag(RowLevelSecurityProtectedModel):
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "resource_tags"
- indexes = [
- GinIndex(fields=["text_search"], name="gin_resource_tags_search_idx"),
- ]
-
constraints = [
models.UniqueConstraint(
fields=("tenant_id", "key", "value"),
@@ -704,6 +794,12 @@ class Resource(RowLevelSecurityProtectedModel):
metadata = models.TextField(blank=True, null=True)
details = models.TextField(blank=True, null=True)
partition = models.TextField(blank=True, null=True)
+ groups = ArrayField(
+ models.CharField(max_length=100),
+ blank=True,
+ null=True,
+ help_text="Groups for categorization (e.g., compute, storage, IAM)",
+ )
failed_findings_count = models.IntegerField(default=0)
@@ -752,7 +848,6 @@ class Resource(RowLevelSecurityProtectedModel):
fields=["tenant_id", "service", "region", "type"],
name="resource_tenant_metadata_idx",
),
- GinIndex(fields=["text_search"], name="gin_resources_search_idx"),
models.Index(fields=["tenant_id", "id"], name="resources_tenant_id_idx"),
models.Index(
fields=["tenant_id", "provider_id"],
@@ -890,6 +985,11 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
null=True,
help_text="Categories from check metadata for efficient filtering",
)
+ resource_groups = models.TextField(
+ blank=True,
+ null=True,
+ help_text="Resource group from check metadata for efficient filtering",
+ )
# Relationships
scan = models.ForeignKey(to=Scan, related_name="findings", on_delete=models.CASCADE)
@@ -932,23 +1032,19 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
indexes = [
models.Index(fields=["tenant_id", "id"], name="findings_tenant_and_id_idx"),
- GinIndex(fields=["text_search"], name="gin_findings_search_idx"),
models.Index(fields=["tenant_id", "scan_id"], name="find_tenant_scan_idx"),
models.Index(
fields=["tenant_id", "scan_id", "id"], name="find_tenant_scan_id_idx"
),
models.Index(
- fields=["tenant_id", "id"],
- condition=Q(delta="new"),
- name="find_delta_new_idx",
+ condition=models.Q(status=StatusChoices.FAIL, delta="new"),
+ fields=["tenant_id", "scan_id"],
+ name="find_tenant_scan_fail_new_idx",
),
models.Index(
fields=["tenant_id", "uid", "-inserted_at"],
name="find_tenant_uid_inserted_idx",
),
- GinIndex(fields=["resource_services"], name="gin_find_service_idx"),
- GinIndex(fields=["resource_regions"], name="gin_find_region_idx"),
- GinIndex(fields=["resource_types"], name="gin_find_rtype_idx"),
models.Index(
fields=["tenant_id", "scan_id", "check_id"],
name="find_tenant_scan_check_idx",
@@ -1016,10 +1112,6 @@ class ResourceFindingMapping(PostgresPartitionedModel, RowLevelSecurityProtected
# - id
indexes = [
- models.Index(
- fields=["tenant_id", "finding_id"],
- name="rfm_tenant_finding_idx",
- ),
models.Index(
fields=["tenant_id", "resource_id"],
name="rfm_tenant_resource_idx",
@@ -1336,14 +1428,6 @@ class ComplianceOverview(RowLevelSecurityProtectedModel):
statements=["SELECT", "INSERT", "DELETE"],
),
]
- indexes = [
- models.Index(fields=["compliance_id"], name="comp_ov_cp_id_idx"),
- models.Index(fields=["requirements_failed"], name="comp_ov_req_fail_idx"),
- models.Index(
- fields=["compliance_id", "requirements_failed"],
- name="comp_ov_cp_id_req_fail_idx",
- ),
- ]
class JSONAPIMeta:
resource_name = "compliance-overviews"
@@ -1509,10 +1593,6 @@ class ScanSummary(RowLevelSecurityProtectedModel):
fields=["tenant_id", "scan_id"],
name="scan_summaries_tenant_scan_idx",
),
- models.Index(
- fields=["tenant_id", "scan_id", "service"],
- name="ss_tenant_scan_service_idx",
- ),
models.Index(
fields=["tenant_id", "scan_id", "severity"],
name="ss_tenant_scan_severity_idx",
@@ -1927,7 +2007,7 @@ class SAMLConfiguration(RowLevelSecurityProtectedModel):
class ResourceScanSummary(RowLevelSecurityProtectedModel):
scan_id = models.UUIDField(default=uuid7, db_index=True)
- resource_id = models.UUIDField(default=uuid4, db_index=True)
+ resource_id = models.UUIDField(default=uuid4)
service = models.CharField(max_length=100)
region = models.CharField(max_length=100)
resource_type = models.CharField(max_length=100)
@@ -2032,6 +2112,67 @@ class ScanCategorySummary(RowLevelSecurityProtectedModel):
resource_name = "scan-category-summaries"
+class ScanGroupSummary(RowLevelSecurityProtectedModel):
+ """
+ Pre-aggregated resource group metrics per scan by severity.
+
+ Stores one row per (resource_group, severity) combination per scan for efficient
+ overview queries. Resource groups come from check_metadata.Group.
+
+ Count relationships (each is a subset of the previous):
+ - total_findings >= failed_findings >= new_failed_findings
+ """
+
+ id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
+ inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
+
+ scan = models.ForeignKey(
+ Scan,
+ on_delete=models.CASCADE,
+ related_name="resource_group_summaries",
+ related_query_name="resource_group_summary",
+ )
+
+ resource_group = models.CharField(max_length=50)
+ severity = SeverityEnumField(choices=SeverityChoices)
+
+ total_findings = models.IntegerField(
+ default=0, help_text="Non-muted findings (PASS + FAIL)"
+ )
+ failed_findings = models.IntegerField(
+ default=0, help_text="Non-muted FAIL findings (subset of total_findings)"
+ )
+ new_failed_findings = models.IntegerField(
+ default=0,
+ help_text="Non-muted FAIL with delta='new' (subset of failed_findings)",
+ )
+ resources_count = models.IntegerField(
+ default=0, help_text="Count of distinct resource_uid values"
+ )
+
+ class Meta(RowLevelSecurityProtectedModel.Meta):
+ db_table = "scan_resource_group_summaries"
+
+ indexes = [
+ models.Index(fields=["tenant_id", "scan"], name="srgs_tenant_scan_idx"),
+ ]
+
+ constraints = [
+ models.UniqueConstraint(
+ fields=("tenant_id", "scan_id", "resource_group", "severity"),
+ name="unique_resource_group_severity_per_scan",
+ ),
+ RowLevelSecurityConstraint(
+ field="tenant_id",
+ name="rls_on_%(class)s",
+ statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
+ ),
+ ]
+
+ class JSONAPIMeta:
+ resource_name = "scan-resource-group-summaries"
+
+
class LighthouseConfiguration(RowLevelSecurityProtectedModel):
"""
Stores configuration and API keys for LLM services.
diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml
index 2e238a5ad4..5acfad545c 100644
--- a/api/src/backend/api/specs/v1.yaml
+++ b/api/src/backend/api/specs/v1.yaml
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
- version: 1.18.0
+ version: 1.19.0
description: |-
Prowler API specification.
@@ -280,6 +280,439 @@ paths:
schema:
$ref: '#/components/schemas/OpenApiResponseResponse'
description: API key was successfully revoked
+ /api/v1/attack-paths-scans:
+ get:
+ operationId: attack_paths_scans_list
+ description: Retrieve Attack Paths scans for the tenant with support for filtering,
+ ordering, and pagination.
+ summary: List Attack Paths scans
+ parameters:
+ - in: query
+ name: fields[attack-paths-scans]
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - state
+ - progress
+ - provider
+ - provider_alias
+ - provider_type
+ - provider_uid
+ - scan
+ - task
+ - inserted_at
+ - started_at
+ - completed_at
+ - duration
+ description: endpoint return only specific fields in the response on a per-type
+ basis by including a fields[TYPE] query parameter.
+ explode: false
+ - in: query
+ name: filter[completed_at]
+ schema:
+ type: string
+ format: date
+ - in: query
+ name: filter[inserted_at]
+ schema:
+ type: string
+ format: date
+ - in: query
+ name: filter[provider]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider__in]
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[provider_alias]
+ schema:
+ type: string
+ - in: query
+ name: filter[provider_alias__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[provider_alias__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[provider_type]
+ schema:
+ type: string
+ x-spec-enum-id: 684bf4173d2b754f
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - gcp
+ - github
+ - iac
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - oraclecloud
+ description: |-
+ * `aws` - AWS
+ * `azure` - Azure
+ * `gcp` - GCP
+ * `kubernetes` - Kubernetes
+ * `m365` - M365
+ * `github` - GitHub
+ * `mongodbatlas` - MongoDB Atlas
+ * `iac` - IaC
+ * `oraclecloud` - Oracle Cloud Infrastructure
+ * `alibabacloud` - Alibaba Cloud
+ - in: query
+ name: filter[provider_type__in]
+ schema:
+ type: array
+ items:
+ type: string
+ x-spec-enum-id: 684bf4173d2b754f
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - gcp
+ - github
+ - iac
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - oraclecloud
+ description: |-
+ Multiple values may be separated by commas.
+
+ * `aws` - AWS
+ * `azure` - Azure
+ * `gcp` - GCP
+ * `kubernetes` - Kubernetes
+ * `m365` - M365
+ * `github` - GitHub
+ * `mongodbatlas` - MongoDB Atlas
+ * `iac` - IaC
+ * `oraclecloud` - Oracle Cloud Infrastructure
+ * `alibabacloud` - Alibaba Cloud
+ explode: false
+ style: form
+ - in: query
+ name: filter[provider_uid]
+ schema:
+ type: string
+ - in: query
+ name: filter[provider_uid__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[provider_uid__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[scan]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[scan__in]
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - name: filter[search]
+ required: false
+ in: query
+ description: A search term.
+ schema:
+ type: string
+ - in: query
+ name: filter[started_at]
+ schema:
+ type: string
+ format: date
+ - in: query
+ name: filter[state]
+ schema:
+ type: string
+ x-spec-enum-id: d38ba07264e1ed34
+ enum:
+ - available
+ - cancelled
+ - completed
+ - executing
+ - failed
+ - scheduled
+ description: |-
+ * `available` - Available
+ * `scheduled` - Scheduled
+ * `executing` - Executing
+ * `completed` - Completed
+ * `failed` - Failed
+ * `cancelled` - Cancelled
+ - in: query
+ name: filter[state__in]
+ schema:
+ type: array
+ items:
+ type: string
+ x-spec-enum-id: d38ba07264e1ed34
+ enum:
+ - available
+ - cancelled
+ - completed
+ - executing
+ - failed
+ - scheduled
+ description: |-
+ Multiple values may be separated by commas.
+
+ * `available` - Available
+ * `scheduled` - Scheduled
+ * `executing` - Executing
+ * `completed` - Completed
+ * `failed` - Failed
+ * `cancelled` - Cancelled
+ explode: false
+ style: form
+ - in: query
+ name: include
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - provider
+ - scan
+ - task
+ description: include query parameter to allow the client to customize which
+ related resources should be returned.
+ explode: false
+ - name: page[number]
+ required: false
+ in: query
+ description: A page number within the paginated result set.
+ schema:
+ type: integer
+ - name: page[size]
+ required: false
+ in: query
+ description: Number of results to return per page.
+ schema:
+ type: integer
+ - name: sort
+ required: false
+ in: query
+ description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - inserted_at
+ - -inserted_at
+ - started_at
+ - -started_at
+ explode: false
+ tags:
+ - Attack Paths
+ security:
+ - JWT or API Key: []
+ responses:
+ '200':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/PaginatedAttackPathsScanList'
+ description: ''
+ /api/v1/attack-paths-scans/{id}:
+ get:
+ operationId: attack_paths_scans_retrieve
+ description: Fetch full details for a specific Attack Paths scan.
+ summary: Retrieve Attack Paths scan details
+ parameters:
+ - in: query
+ name: fields[attack-paths-scans]
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - state
+ - progress
+ - provider
+ - provider_alias
+ - provider_type
+ - provider_uid
+ - scan
+ - task
+ - inserted_at
+ - started_at
+ - completed_at
+ - duration
+ 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 attack paths scan.
+ required: true
+ - in: query
+ name: include
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - provider
+ - scan
+ - task
+ description: include query parameter to allow the client to customize which
+ related resources should be returned.
+ explode: false
+ tags:
+ - Attack Paths
+ security:
+ - JWT or API Key: []
+ responses:
+ '200':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/AttackPathsScanResponse'
+ description: ''
+ /api/v1/attack-paths-scans/{id}/queries:
+ get:
+ operationId: attack_paths_scans_queries_retrieve
+ description: Retrieve the catalog of Attack Paths queries available for this
+ Attack Paths scan.
+ summary: List attack paths queries
+ parameters:
+ - in: query
+ name: fields[attack-paths-scans]
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - state
+ - progress
+ - provider
+ - provider_alias
+ - provider_type
+ - provider_uid
+ - scan
+ - task
+ - inserted_at
+ - started_at
+ - completed_at
+ - duration
+ 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 attack paths scan.
+ required: true
+ - in: query
+ name: include
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - provider
+ - scan
+ - task
+ description: include query parameter to allow the client to customize which
+ related resources should be returned.
+ explode: false
+ tags:
+ - Attack Paths
+ security:
+ - JWT or API Key: []
+ responses:
+ '200':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/PaginatedAttackPathsQueryList'
+ description: ''
+ '404':
+ description: No queries found for the selected provider
+ /api/v1/attack-paths-scans/{id}/queries/run:
+ post:
+ operationId: attack_paths_scans_queries_run_create
+ description: Execute the selected Attack Paths query against the Attack Paths
+ graph and return the resulting subgraph.
+ summary: Execute an Attack Paths query
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ format: uuid
+ description: A UUID string identifying this attack paths scan.
+ required: true
+ tags:
+ - Attack Paths
+ requestBody:
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/AttackPathsQueryRunRequestRequest'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/AttackPathsQueryRunRequestRequest'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/AttackPathsQueryRunRequestRequest'
+ required: true
+ security:
+ - JWT or API Key: []
+ responses:
+ '200':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/OpenApiResponseResponse'
+ description: ''
+ '400':
+ description: Bad request (e.g., Unknown Attack Paths query for the selected
+ provider)
+ '404':
+ description: No attack paths found for the given query and parameters
+ '500':
+ description: Attack Paths query execution failed due to a database error
/api/v1/compliance-overviews:
get:
operationId: compliance_overviews_list
@@ -712,6 +1145,7 @@ paths:
- check_id
- check_metadata
- categories
+ - resource_groups
- raw_result
- inserted_at
- updated_at
@@ -988,6 +1422,19 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[resource_groups]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_groups__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[resource_name]
schema:
@@ -1239,6 +1686,7 @@ paths:
- check_id
- check_metadata
- categories
+ - resource_groups
- raw_result
- inserted_at
- updated_at
@@ -1560,6 +2008,19 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[resource_groups]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_groups__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[resource_name]
schema:
@@ -1789,6 +2250,7 @@ paths:
- check_id
- check_metadata
- categories
+ - resource_groups
- raw_result
- inserted_at
- updated_at
@@ -2040,6 +2502,19 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[resource_groups]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_groups__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[resource_name]
schema:
@@ -2251,6 +2726,7 @@ paths:
- regions
- resource_types
- categories
+ - groups
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
@@ -2518,6 +2994,19 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[resource_groups]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_groups__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[resource_name]
schema:
@@ -2742,6 +3231,7 @@ paths:
- regions
- resource_types
- categories
+ - groups
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
@@ -2984,6 +3474,19 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[resource_groups]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_groups__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[resource_name]
schema:
@@ -4968,12 +5471,12 @@ paths:
description: ''
/api/v1/overviews/compliance-watchlist:
get:
- operationId: overviews_compliance_watchlist_retrieve
- description: |-
- Get compliance watchlist overview with FAIL-dominant aggregation.
-
- Without filters: uses pre-aggregated TenantComplianceSummary (~70 rows).
- With provider filters: queries ProviderComplianceScore with FAIL-dominant logic.
+ operationId: overviews_compliance_watchlist_list
+ description: 'Retrieve compliance metrics with FAIL-dominant aggregation. Without
+ filters: uses pre-aggregated TenantComplianceSummary. With provider filters:
+ queries ProviderComplianceScore with FAIL-dominant logic where any FAIL in
+ a requirement marks it as failed.'
+ summary: Get compliance watchlist overview
parameters:
- in: query
name: fields[compliance-watchlist-overviews]
@@ -4991,6 +5494,119 @@ paths:
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
+ - in: query
+ name: filter[provider_id]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_id__in]
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[provider_type]
+ schema:
+ type: string
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - gcp
+ - github
+ - iac
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - oraclecloud
+ description: |-
+ * `aws` - AWS
+ * `azure` - Azure
+ * `gcp` - GCP
+ * `kubernetes` - Kubernetes
+ * `m365` - M365
+ * `github` - GitHub
+ * `mongodbatlas` - MongoDB Atlas
+ * `iac` - IaC
+ * `oraclecloud` - Oracle Cloud Infrastructure
+ * `alibabacloud` - Alibaba Cloud
+ - in: query
+ name: filter[provider_type__in]
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - gcp
+ - github
+ - iac
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - oraclecloud
+ description: |-
+ Multiple values may be separated by commas.
+
+ * `aws` - AWS
+ * `azure` - Azure
+ * `gcp` - GCP
+ * `kubernetes` - Kubernetes
+ * `m365` - M365
+ * `github` - GitHub
+ * `mongodbatlas` - MongoDB Atlas
+ * `iac` - IaC
+ * `oraclecloud` - Oracle Cloud Infrastructure
+ * `alibabacloud` - Alibaba Cloud
+ explode: false
+ style: form
+ - name: filter[search]
+ required: false
+ in: query
+ description: A search term.
+ schema:
+ type: string
+ - name: page[number]
+ required: false
+ in: query
+ description: A page number within the paginated result set.
+ schema:
+ type: integer
+ - name: page[size]
+ required: false
+ in: query
+ description: Number of results to return per page.
+ schema:
+ type: integer
+ - name: sort
+ required: false
+ in: query
+ description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - id
+ - -id
+ - compliance_id
+ - -compliance_id
+ - requirements_passed
+ - -requirements_passed
+ - requirements_failed
+ - -requirements_failed
+ - requirements_manual
+ - -requirements_manual
+ - total_requirements
+ - -total_requirements
+ explode: false
tags:
- Overview
security:
@@ -5000,7 +5616,7 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/ComplianceWatchlistOverviewResponse'
+ $ref: '#/components/schemas/PaginatedComplianceWatchlistOverviewList'
description: ''
/api/v1/overviews/findings:
get:
@@ -5797,6 +6413,170 @@ paths:
schema:
$ref: '#/components/schemas/OverviewRegionResponse'
description: ''
+ /api/v1/overviews/resource-groups:
+ get:
+ operationId: overviews_resource_groups_list
+ description: Retrieve aggregated resource group metrics from latest completed
+ scans per provider. Returns one row per resource group with total, failed,
+ and new failed findings counts, plus a severity breakdown showing failed findings
+ per severity level, and a count of distinct resources evaluated per group.
+ summary: Get resource group overview
+ parameters:
+ - in: query
+ name: fields[resource-group-overviews]
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - id
+ - total_findings
+ - failed_findings
+ - new_failed_findings
+ - resources_count
+ - severity
+ description: endpoint return only specific fields in the response on a per-type
+ basis by including a fields[TYPE] query parameter.
+ explode: false
+ - in: query
+ name: filter[provider_id]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_id__in]
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[provider_type]
+ schema:
+ type: string
+ x-spec-enum-id: 684bf4173d2b754f
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - gcp
+ - github
+ - iac
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - oraclecloud
+ description: |-
+ * `aws` - AWS
+ * `azure` - Azure
+ * `gcp` - GCP
+ * `kubernetes` - Kubernetes
+ * `m365` - M365
+ * `github` - GitHub
+ * `mongodbatlas` - MongoDB Atlas
+ * `iac` - IaC
+ * `oraclecloud` - Oracle Cloud Infrastructure
+ * `alibabacloud` - Alibaba Cloud
+ - in: query
+ name: filter[provider_type__in]
+ schema:
+ type: array
+ items:
+ type: string
+ x-spec-enum-id: 684bf4173d2b754f
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - gcp
+ - github
+ - iac
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - oraclecloud
+ description: |-
+ Multiple values may be separated by commas.
+
+ * `aws` - AWS
+ * `azure` - Azure
+ * `gcp` - GCP
+ * `kubernetes` - Kubernetes
+ * `m365` - M365
+ * `github` - GitHub
+ * `mongodbatlas` - MongoDB Atlas
+ * `iac` - IaC
+ * `oraclecloud` - Oracle Cloud Infrastructure
+ * `alibabacloud` - Alibaba Cloud
+ explode: false
+ style: form
+ - in: query
+ name: filter[resource_group]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_group__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - name: filter[search]
+ required: false
+ in: query
+ description: A search term.
+ schema:
+ type: string
+ - name: page[number]
+ required: false
+ in: query
+ description: A page number within the paginated result set.
+ schema:
+ type: integer
+ - name: page[size]
+ required: false
+ in: query
+ description: Number of results to return per page.
+ schema:
+ type: integer
+ - name: sort
+ required: false
+ in: query
+ description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - id
+ - -id
+ - total_findings
+ - -total_findings
+ - failed_findings
+ - -failed_findings
+ - new_failed_findings
+ - -new_failed_findings
+ - resources_count
+ - -resources_count
+ - severity
+ - -severity
+ explode: false
+ tags:
+ - Overview
+ security:
+ - JWT or API Key: []
+ responses:
+ '200':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/PaginatedResourceGroupOverviewList'
+ description: ''
/api/v1/overviews/services:
get:
operationId: overviews_services_retrieve
@@ -7333,10 +8113,24 @@ paths:
- metadata
- details
- partition
+ - groups
- type
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
+ - in: query
+ name: filter[groups]
+ schema:
+ type: string
+ - in: query
+ name: filter[groups__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[inserted_at]
schema:
@@ -7392,6 +8186,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_id]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_id__in]
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[provider_type]
schema:
@@ -7673,6 +8482,7 @@ paths:
- metadata
- details
- partition
+ - groups
- type
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
@@ -7707,6 +8517,64 @@ paths:
schema:
$ref: '#/components/schemas/ResourceResponse'
description: ''
+ /api/v1/resources/{id}/events:
+ get:
+ operationId: resources_events_list
+ description: |-
+ Retrieve events showing modification history for a resource. Returns who modified the resource and when. Currently only available for AWS resources.
+
+ **Note:** Some events may not appear due to CloudTrail indexing limitations. Not all AWS API calls record the resource identifier in a searchable format.
+ summary: Get events for a resource
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ format: uuid
+ description: A UUID string identifying this resource.
+ required: true
+ - in: query
+ name: include_read_events
+ schema:
+ type: boolean
+ description: 'Include read-only events (Describe*, Get*, List*, etc.). Default:
+ false. Set to true to include all events.'
+ - in: query
+ name: lookback_days
+ schema:
+ type: integer
+ description: 'Number of days to look back (default: 90, min: 1, max: 90).'
+ - name: page[number]
+ required: false
+ in: query
+ description: A page number within the paginated result set.
+ schema:
+ type: integer
+ - in: query
+ name: page[size]
+ schema:
+ type: integer
+ description: 'Maximum number of events to return (default: 50, min: 1, max:
+ 50).'
+ tags:
+ - Resource
+ security:
+ - JWT or API Key: []
+ responses:
+ '200':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/PaginatedResourceEventList'
+ description: ''
+ '400':
+ description: Invalid provider or parameters
+ '500':
+ description: Unexpected error retrieving events
+ '502':
+ description: Provider credentials invalid, expired, or lack required permissions
+ '503':
+ description: Provider service unavailable
/api/v1/resources/latest:
get:
operationId: resources_latest_retrieve
@@ -7735,10 +8603,24 @@ paths:
- metadata
- details
- partition
+ - groups
- type
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
+ - in: query
+ name: filter[groups]
+ schema:
+ type: string
+ - in: query
+ name: filter[groups__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[name]
schema:
@@ -7779,6 +8661,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_id]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_id__in]
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[provider_type]
schema:
@@ -8003,9 +8900,23 @@ paths:
- services
- regions
- types
+ - groups
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
+ - in: query
+ name: filter[groups]
+ schema:
+ type: string
+ - in: query
+ name: filter[groups__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[inserted_at]
schema:
@@ -8061,6 +8972,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_id]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_id__in]
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[provider_type]
schema:
@@ -8306,9 +9232,23 @@ paths:
- services
- regions
- types
+ - groups
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
+ - in: query
+ name: filter[groups]
+ schema:
+ type: string
+ - in: query
+ name: filter[groups__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[name]
schema:
@@ -8349,6 +9289,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_id]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_id__in]
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[provider_type]
schema:
@@ -11321,6 +12276,349 @@ paths:
description: ''
components:
schemas:
+ AttackPathsNode:
+ type: object
+ required:
+ - type
+ additionalProperties: false
+ properties:
+ type:
+ type: string
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ enum:
+ - attack-paths-query-result-nodes
+ attributes:
+ type: object
+ properties:
+ id:
+ type: string
+ labels:
+ type: array
+ items:
+ type: string
+ properties:
+ type: object
+ additionalProperties: {}
+ required:
+ - id
+ - labels
+ - properties
+ AttackPathsQuery:
+ type: object
+ required:
+ - type
+ - id
+ additionalProperties: false
+ properties:
+ type:
+ type: string
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ enum:
+ - attack-paths-queries
+ id: {}
+ attributes:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ description:
+ type: string
+ provider:
+ type: string
+ parameters:
+ type: array
+ items:
+ $ref: '#/components/schemas/AttackPathsQueryParameter'
+ required:
+ - id
+ - name
+ - description
+ - provider
+ - parameters
+ AttackPathsQueryParameter:
+ type: object
+ required:
+ - type
+ - id
+ additionalProperties: false
+ properties:
+ type:
+ type: string
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ enum:
+ - attack-paths-query-parameters
+ id: {}
+ attributes:
+ type: object
+ properties:
+ name:
+ type: string
+ label:
+ type: string
+ data_type:
+ type: string
+ default: string
+ description:
+ type: string
+ nullable: true
+ placeholder:
+ type: string
+ nullable: true
+ required:
+ - name
+ - label
+ AttackPathsQueryResult:
+ type: object
+ required:
+ - type
+ additionalProperties: false
+ properties:
+ type:
+ type: string
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ enum:
+ - attack-paths-query-results
+ attributes:
+ type: object
+ properties:
+ nodes:
+ type: array
+ items:
+ $ref: '#/components/schemas/AttackPathsNode'
+ relationships:
+ type: array
+ items:
+ $ref: '#/components/schemas/AttackPathsRelationship'
+ required:
+ - nodes
+ - relationships
+ AttackPathsQueryRunRequestRequest:
+ type: object
+ properties:
+ data:
+ type: object
+ required:
+ - type
+ additionalProperties: false
+ properties:
+ type:
+ type: string
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ enum:
+ - attack-paths-query-run-requests
+ attributes:
+ type: object
+ properties:
+ id:
+ type: string
+ minLength: 1
+ parameters:
+ type: object
+ additionalProperties: {}
+ required:
+ - id
+ required:
+ - data
+ AttackPathsRelationship:
+ type: object
+ required:
+ - type
+ additionalProperties: false
+ properties:
+ type:
+ type: string
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ enum:
+ - attack-paths-query-result-relationships
+ attributes:
+ type: object
+ properties:
+ id:
+ type: string
+ label:
+ type: string
+ source:
+ type: string
+ target:
+ type: string
+ properties:
+ type: object
+ additionalProperties: {}
+ required:
+ - id
+ - label
+ - source
+ - target
+ - properties
+ AttackPathsScan:
+ type: object
+ required:
+ - type
+ - id
+ additionalProperties: false
+ properties:
+ type:
+ type: string
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ enum:
+ - attack-paths-scans
+ id:
+ type: string
+ format: uuid
+ attributes:
+ type: object
+ properties:
+ state:
+ enum:
+ - available
+ - scheduled
+ - executing
+ - completed
+ - failed
+ - cancelled
+ type: string
+ description: |-
+ * `available` - Available
+ * `scheduled` - Scheduled
+ * `executing` - Executing
+ * `completed` - Completed
+ * `failed` - Failed
+ * `cancelled` - Cancelled
+ x-spec-enum-id: d38ba07264e1ed34
+ readOnly: true
+ progress:
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ provider_alias:
+ type: string
+ readOnly: true
+ provider_type:
+ type: string
+ readOnly: true
+ provider_uid:
+ type: string
+ readOnly: true
+ inserted_at:
+ type: string
+ format: date-time
+ readOnly: true
+ started_at:
+ type: string
+ format: date-time
+ nullable: true
+ completed_at:
+ type: string
+ format: date-time
+ nullable: true
+ duration:
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
+ nullable: true
+ description: Duration in seconds
+ relationships:
+ type: object
+ properties:
+ provider:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ id:
+ type: string
+ format: uuid
+ type:
+ type: string
+ enum:
+ - providers
+ title: Resource Type Name
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common
+ attributes and relationships.
+ required:
+ - id
+ - type
+ required:
+ - data
+ description: The identifier of the related object.
+ title: Resource Identifier
+ scan:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ id:
+ type: string
+ format: uuid
+ type:
+ type: string
+ enum:
+ - scans
+ title: Resource Type Name
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common
+ attributes and relationships.
+ required:
+ - id
+ - type
+ required:
+ - data
+ description: The identifier of the related object.
+ title: Resource Identifier
+ nullable: true
+ task:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ id:
+ type: string
+ format: uuid
+ type:
+ type: string
+ enum:
+ - tasks
+ title: Resource Type Name
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common
+ attributes and relationships.
+ required:
+ - id
+ - type
+ required:
+ - data
+ description: The identifier of the related object.
+ title: Resource Identifier
+ nullable: true
+ required:
+ - provider
+ AttackPathsScanResponse:
+ type: object
+ properties:
+ data:
+ $ref: '#/components/schemas/AttackPathsScan'
+ required:
+ - data
AttackSurfaceOverview:
type: object
required:
@@ -11573,13 +12871,6 @@ components:
- requirements_failed
- requirements_manual
- total_requirements
- ComplianceWatchlistOverviewResponse:
- type: object
- properties:
- data:
- $ref: '#/components/schemas/ComplianceWatchlistOverview'
- required:
- - data
Finding:
type: object
required:
@@ -11654,6 +12945,10 @@ components:
maxLength: 100
nullable: true
description: Categories from check metadata for efficient filtering
+ resource_groups:
+ type: string
+ nullable: true
+ description: Resource group from check metadata for efficient filtering
raw_result: {}
inserted_at:
type: string
@@ -11808,6 +13103,10 @@ components:
type: array
items:
type: string
+ groups:
+ type: array
+ items:
+ type: string
required:
- services
- regions
@@ -14515,6 +15814,24 @@ components:
$ref: '#/components/schemas/OverviewSeverity'
required:
- data
+ PaginatedAttackPathsQueryList:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/AttackPathsQuery'
+ required:
+ - data
+ PaginatedAttackPathsScanList:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/AttackPathsScan'
+ required:
+ - data
PaginatedAttackSurfaceOverviewList:
type: object
properties:
@@ -14560,6 +15877,15 @@ components:
$ref: '#/components/schemas/ComplianceOverview'
required:
- data
+ PaginatedComplianceWatchlistOverviewList:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/ComplianceWatchlistOverview'
+ required:
+ - data
PaginatedFindingList:
type: object
properties:
@@ -14677,6 +16003,24 @@ components:
$ref: '#/components/schemas/ProviderSecret'
required:
- data
+ PaginatedResourceEventList:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/ResourceEvent'
+ required:
+ - data
+ PaginatedResourceGroupOverviewList:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/ResourceGroupOverview'
+ required:
+ - data
PaginatedResourceList:
type: object
properties:
@@ -18681,6 +20025,14 @@ components:
type: string
readOnly: true
nullable: true
+ groups:
+ type: array
+ items:
+ type: string
+ maxLength: 100
+ readOnly: true
+ nullable: true
+ description: Groups for categorization (e.g., compute, storage, IAM)
type:
type: string
readOnly: true
@@ -18742,6 +20094,101 @@ components:
readOnly: true
required:
- provider
+ ResourceEvent:
+ type: object
+ required:
+ - type
+ - id
+ additionalProperties: false
+ properties:
+ type:
+ type: string
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ enum:
+ - resource-events
+ id: {}
+ attributes:
+ type: object
+ properties:
+ id:
+ type: string
+ event_time:
+ type: string
+ format: date-time
+ event_name:
+ type: string
+ event_source:
+ type: string
+ actor:
+ type: string
+ actor_uid:
+ type: string
+ nullable: true
+ actor_type:
+ type: string
+ nullable: true
+ source_ip_address:
+ type: string
+ nullable: true
+ user_agent:
+ type: string
+ nullable: true
+ request_data:
+ nullable: true
+ response_data:
+ nullable: true
+ error_code:
+ type: string
+ nullable: true
+ error_message:
+ type: string
+ nullable: true
+ required:
+ - id
+ - event_time
+ - event_name
+ - event_source
+ - actor
+ ResourceGroupOverview:
+ type: object
+ required:
+ - type
+ - id
+ additionalProperties: false
+ properties:
+ type:
+ type: string
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ enum:
+ - resource-group-overviews
+ id: {}
+ attributes:
+ type: object
+ properties:
+ id:
+ type: string
+ total_findings:
+ type: integer
+ failed_findings:
+ type: integer
+ new_failed_findings:
+ type: integer
+ resources_count:
+ type: integer
+ severity:
+ description: 'Severity breakdown: {informational, low, medium, high,
+ critical}'
+ required:
+ - id
+ - total_findings
+ - failed_findings
+ - new_failed_findings
+ - resources_count
+ - severity
ResourceMetadata:
type: object
required:
@@ -18772,10 +20219,15 @@ components:
type: array
items:
type: string
+ groups:
+ type: array
+ items:
+ type: string
required:
- services
- regions
- types
+ - groups
ResourceMetadataResponse:
type: object
properties:
@@ -20795,6 +22247,8 @@ tags:
revoking tasks that have not started.
- name: Scan
description: Endpoints for triggering manual scans and viewing scan results.
+- name: Attack Paths
+ description: Endpoints for Attack Paths scan status and executing Attack Paths queries.
- name: Schedule
description: Endpoints for managing scan schedules, allowing configuration of automated
scans with different scheduling options.
diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py
index 1509705894..712bc33882 100644
--- a/api/src/backend/api/tests/test_apps.py
+++ b/api/src/backend/api/tests/test_apps.py
@@ -1,10 +1,13 @@
import os
+import sys
+import types
from pathlib import Path
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import pytest
from django.conf import settings
+import api
import api.apps as api_apps_module
from api.apps import (
ApiConfig,
@@ -150,3 +153,82 @@ def test_ensure_crypto_keys_skips_when_env_vars(monkeypatch, tmp_path):
# Assert: orchestrator did not trigger generation when env present
assert called["ensure"] is False
+
+
+@pytest.fixture(autouse=True)
+def stub_api_modules():
+ """Provide dummy modules imported during ApiConfig.ready()."""
+ created = []
+ for name in ("api.schema_extensions", "api.signals"):
+ if name not in sys.modules:
+ sys.modules[name] = types.ModuleType(name)
+ created.append(name)
+
+ yield
+
+ for name in created:
+ sys.modules.pop(name, None)
+
+
+def _set_argv(monkeypatch, argv):
+ monkeypatch.setattr(sys, "argv", argv, raising=False)
+
+
+def _set_testing(monkeypatch, value):
+ monkeypatch.setattr(settings, "TESTING", value, raising=False)
+
+
+def _make_app():
+ return ApiConfig("api", api)
+
+
+def test_ready_initializes_driver_for_api_process(monkeypatch):
+ config = _make_app()
+ _set_argv(monkeypatch, ["gunicorn"])
+ _set_testing(monkeypatch, False)
+
+ with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
+ "api.attack_paths.database.init_driver"
+ ) as init_driver:
+ config.ready()
+
+ init_driver.assert_called_once()
+
+
+def test_ready_skips_driver_for_celery(monkeypatch):
+ config = _make_app()
+ _set_argv(monkeypatch, ["celery", "-A", "api"])
+ _set_testing(monkeypatch, False)
+
+ with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
+ "api.attack_paths.database.init_driver"
+ ) as init_driver:
+ config.ready()
+
+ init_driver.assert_not_called()
+
+
+def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch):
+ config = _make_app()
+ _set_argv(monkeypatch, ["manage.py", "migrate"])
+ _set_testing(monkeypatch, False)
+
+ with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
+ "api.attack_paths.database.init_driver"
+ ) as init_driver:
+ config.ready()
+
+ init_driver.assert_not_called()
+
+
+def test_ready_skips_driver_when_testing(monkeypatch):
+ config = _make_app()
+ _set_argv(monkeypatch, ["gunicorn"])
+ _set_testing(monkeypatch, True)
+
+ with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
+ "api.attack_paths.database.init_driver"
+ ) as init_driver:
+ config.ready()
+
+ init_driver.assert_not_called()
diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py
new file mode 100644
index 0000000000..2c4e1484f8
--- /dev/null
+++ b/api/src/backend/api/tests/test_attack_paths.py
@@ -0,0 +1,172 @@
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from rest_framework.exceptions import APIException, ValidationError
+
+from api.attack_paths import database as graph_database
+from api.attack_paths import views_helpers
+
+
+def test_normalize_run_payload_extracts_attributes_section():
+ payload = {
+ "data": {
+ "id": "ignored",
+ "attributes": {
+ "id": "aws-rds",
+ "parameters": {"ip": "192.0.2.0"},
+ },
+ }
+ }
+
+ result = views_helpers.normalize_run_payload(payload)
+
+ assert result == {"id": "aws-rds", "parameters": {"ip": "192.0.2.0"}}
+
+
+def test_normalize_run_payload_passthrough_for_non_dict():
+ sentinel = "not-a-dict"
+ assert views_helpers.normalize_run_payload(sentinel) is sentinel
+
+
+def test_prepare_query_parameters_includes_provider_and_casts(
+ attack_paths_query_definition_factory,
+):
+ definition = attack_paths_query_definition_factory(cast_type=int)
+ result = views_helpers.prepare_query_parameters(
+ definition,
+ {"limit": "5"},
+ provider_uid="123456789012",
+ )
+
+ assert result["provider_uid"] == "123456789012"
+ assert result["limit"] == 5
+
+
+@pytest.mark.parametrize(
+ "provided,expected_message",
+ [
+ ({}, "Missing required parameter"),
+ ({"limit": 10, "extra": True}, "Unknown parameter"),
+ ],
+)
+def test_prepare_query_parameters_validates_names(
+ attack_paths_query_definition_factory, provided, expected_message
+):
+ definition = attack_paths_query_definition_factory()
+
+ with pytest.raises(ValidationError) as exc:
+ views_helpers.prepare_query_parameters(definition, provided, provider_uid="1")
+
+ assert expected_message in str(exc.value)
+
+
+def test_prepare_query_parameters_validates_cast(
+ attack_paths_query_definition_factory,
+):
+ definition = attack_paths_query_definition_factory(cast_type=int)
+
+ with pytest.raises(ValidationError) as exc:
+ views_helpers.prepare_query_parameters(
+ definition,
+ {"limit": "not-an-int"},
+ provider_uid="1",
+ )
+
+ assert "Invalid value" in str(exc.value)
+
+
+def test_execute_attack_paths_query_serializes_graph(
+ attack_paths_query_definition_factory, attack_paths_graph_stub_classes
+):
+ definition = attack_paths_query_definition_factory(
+ id="aws-rds",
+ name="RDS",
+ description="",
+ cypher="MATCH (n) RETURN n",
+ parameters=[],
+ )
+ parameters = {"provider_uid": "123"}
+ attack_paths_scan = SimpleNamespace(graph_database="tenant-db")
+
+ node = attack_paths_graph_stub_classes.Node(
+ element_id="node-1",
+ labels=["AWSAccount"],
+ properties={
+ "name": "account",
+ "complex": {
+ "items": [
+ attack_paths_graph_stub_classes.NativeValue("value"),
+ {"nested": 1},
+ ]
+ },
+ },
+ )
+ relationship = attack_paths_graph_stub_classes.Relationship(
+ element_id="rel-1",
+ rel_type="OWNS",
+ start_node=node,
+ end_node=attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance"], {}),
+ properties={"weight": 1},
+ )
+ graph = SimpleNamespace(nodes=[node], relationships=[relationship])
+
+ run_result = MagicMock()
+ run_result.graph.return_value = graph
+
+ session = MagicMock()
+ session.run.return_value = run_result
+
+ session_ctx = MagicMock()
+ session_ctx.__enter__.return_value = session
+ session_ctx.__exit__.return_value = False
+
+ with patch(
+ "api.attack_paths.views_helpers.graph_database.get_session",
+ return_value=session_ctx,
+ ) as mock_get_session:
+ result = views_helpers.execute_attack_paths_query(
+ attack_paths_scan, definition, parameters
+ )
+
+ mock_get_session.assert_called_once_with("tenant-db")
+ session.run.assert_called_once_with(definition.cypher, parameters)
+ assert result["nodes"][0]["id"] == "node-1"
+ assert result["nodes"][0]["properties"]["complex"]["items"][0] == "value"
+ assert result["relationships"][0]["label"] == "OWNS"
+
+
+def test_execute_attack_paths_query_wraps_graph_errors(
+ attack_paths_query_definition_factory,
+):
+ definition = attack_paths_query_definition_factory(
+ id="aws-rds",
+ name="RDS",
+ description="",
+ cypher="MATCH (n) RETURN n",
+ parameters=[],
+ )
+ attack_paths_scan = SimpleNamespace(graph_database="tenant-db")
+ parameters = {"provider_uid": "123"}
+
+ class ExplodingContext:
+ def __enter__(self):
+ raise graph_database.GraphDatabaseQueryException("boom")
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ with (
+ patch(
+ "api.attack_paths.views_helpers.graph_database.get_session",
+ return_value=ExplodingContext(),
+ ),
+ patch("api.attack_paths.views_helpers.logger") as mock_logger,
+ ):
+ with pytest.raises(APIException):
+ views_helpers.execute_attack_paths_query(
+ attack_paths_scan, definition, parameters
+ )
+
+ mock_logger.error.assert_called_once()
diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py
new file mode 100644
index 0000000000..46ba101c4a
--- /dev/null
+++ b/api/src/backend/api/tests/test_attack_paths_database.py
@@ -0,0 +1,303 @@
+"""
+Tests for Neo4j database lazy initialization.
+
+The Neo4j driver connects on first use by default. API processes may
+eagerly initialize the driver during app startup, while Celery workers
+remain lazy. These tests validate the database module behavior itself.
+"""
+
+import threading
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+class TestLazyInitialization:
+ """Test that Neo4j driver is initialized lazily on first use."""
+
+ @pytest.fixture(autouse=True)
+ def reset_module_state(self):
+ """Reset module-level singleton state before each test."""
+ import api.attack_paths.database as db_module
+
+ original_driver = db_module._driver
+
+ db_module._driver = None
+
+ yield
+
+ db_module._driver = original_driver
+
+ def test_driver_not_initialized_at_import(self):
+ """Driver should be None after module import (no eager connection)."""
+ import api.attack_paths.database as db_module
+
+ assert db_module._driver is None
+
+ @patch("api.attack_paths.database.settings")
+ @patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
+ def test_init_driver_creates_connection_on_first_call(
+ self, mock_driver_factory, mock_settings
+ ):
+ """init_driver() should create connection only when called."""
+ import api.attack_paths.database as db_module
+
+ mock_driver = MagicMock()
+ mock_driver_factory.return_value = mock_driver
+ mock_settings.DATABASES = {
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": 7687,
+ "USER": "neo4j",
+ "PASSWORD": "password",
+ }
+ }
+
+ assert db_module._driver is None
+
+ result = db_module.init_driver()
+
+ mock_driver_factory.assert_called_once()
+ mock_driver.verify_connectivity.assert_called_once()
+ assert result is mock_driver
+ assert db_module._driver is mock_driver
+
+ @patch("api.attack_paths.database.settings")
+ @patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
+ def test_init_driver_returns_cached_driver_on_subsequent_calls(
+ self, mock_driver_factory, mock_settings
+ ):
+ """Subsequent calls should return cached driver without reconnecting."""
+ import api.attack_paths.database as db_module
+
+ mock_driver = MagicMock()
+ mock_driver_factory.return_value = mock_driver
+ mock_settings.DATABASES = {
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": 7687,
+ "USER": "neo4j",
+ "PASSWORD": "password",
+ }
+ }
+
+ first_result = db_module.init_driver()
+ second_result = db_module.init_driver()
+ third_result = db_module.init_driver()
+
+ # Only one connection attempt
+ assert mock_driver_factory.call_count == 1
+ assert mock_driver.verify_connectivity.call_count == 1
+
+ # All calls return same instance
+ assert first_result is second_result is third_result
+
+ @patch("api.attack_paths.database.settings")
+ @patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
+ def test_get_driver_delegates_to_init_driver(
+ self, mock_driver_factory, mock_settings
+ ):
+ """get_driver() should use init_driver() for lazy initialization."""
+ import api.attack_paths.database as db_module
+
+ mock_driver = MagicMock()
+ mock_driver_factory.return_value = mock_driver
+ mock_settings.DATABASES = {
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": 7687,
+ "USER": "neo4j",
+ "PASSWORD": "password",
+ }
+ }
+
+ result = db_module.get_driver()
+
+ assert result is mock_driver
+ mock_driver_factory.assert_called_once()
+
+
+class TestAtexitRegistration:
+ """Test that atexit cleanup handler is registered correctly."""
+
+ @pytest.fixture(autouse=True)
+ def reset_module_state(self):
+ """Reset module-level singleton state before each test."""
+ import api.attack_paths.database as db_module
+
+ original_driver = db_module._driver
+
+ db_module._driver = None
+
+ yield
+
+ db_module._driver = original_driver
+
+ @patch("api.attack_paths.database.settings")
+ @patch("api.attack_paths.database.atexit.register")
+ @patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
+ def test_atexit_registered_on_first_init(
+ self, mock_driver_factory, mock_atexit_register, mock_settings
+ ):
+ """atexit.register should be called on first initialization."""
+ import api.attack_paths.database as db_module
+
+ mock_driver_factory.return_value = MagicMock()
+ mock_settings.DATABASES = {
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": 7687,
+ "USER": "neo4j",
+ "PASSWORD": "password",
+ }
+ }
+
+ db_module.init_driver()
+
+ mock_atexit_register.assert_called_once_with(db_module.close_driver)
+
+ @patch("api.attack_paths.database.settings")
+ @patch("api.attack_paths.database.atexit.register")
+ @patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
+ def test_atexit_registered_only_once(
+ self, mock_driver_factory, mock_atexit_register, mock_settings
+ ):
+ """atexit.register should only be called once across multiple inits.
+
+ The double-checked locking on _driver ensures the atexit registration
+ block only executes once (when _driver is first created).
+ """
+ import api.attack_paths.database as db_module
+
+ mock_driver_factory.return_value = MagicMock()
+ mock_settings.DATABASES = {
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": 7687,
+ "USER": "neo4j",
+ "PASSWORD": "password",
+ }
+ }
+
+ db_module.init_driver()
+ db_module.init_driver()
+ db_module.init_driver()
+
+ # Only registered once because subsequent calls hit the fast path
+ assert mock_atexit_register.call_count == 1
+
+
+class TestCloseDriver:
+ """Test driver cleanup functionality."""
+
+ @pytest.fixture(autouse=True)
+ def reset_module_state(self):
+ """Reset module-level singleton state before each test."""
+ import api.attack_paths.database as db_module
+
+ original_driver = db_module._driver
+
+ db_module._driver = None
+
+ yield
+
+ db_module._driver = original_driver
+
+ def test_close_driver_closes_and_clears_driver(self):
+ """close_driver() should close the driver and set it to None."""
+ import api.attack_paths.database as db_module
+
+ mock_driver = MagicMock()
+ db_module._driver = mock_driver
+
+ db_module.close_driver()
+
+ mock_driver.close.assert_called_once()
+ assert db_module._driver is None
+
+ def test_close_driver_handles_none_driver(self):
+ """close_driver() should handle case where driver is None."""
+ import api.attack_paths.database as db_module
+
+ db_module._driver = None
+
+ # Should not raise
+ db_module.close_driver()
+
+ assert db_module._driver is None
+
+ def test_close_driver_clears_driver_even_on_close_error(self):
+ """Driver should be cleared even if close() raises an exception."""
+ import api.attack_paths.database as db_module
+
+ mock_driver = MagicMock()
+ mock_driver.close.side_effect = Exception("Connection error")
+ db_module._driver = mock_driver
+
+ with pytest.raises(Exception, match="Connection error"):
+ db_module.close_driver()
+
+ # Driver should still be cleared
+ assert db_module._driver is None
+
+
+class TestThreadSafety:
+ """Test thread-safe initialization."""
+
+ @pytest.fixture(autouse=True)
+ def reset_module_state(self):
+ """Reset module-level singleton state before each test."""
+ import api.attack_paths.database as db_module
+
+ original_driver = db_module._driver
+
+ db_module._driver = None
+
+ yield
+
+ db_module._driver = original_driver
+
+ @patch("api.attack_paths.database.settings")
+ @patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
+ def test_concurrent_init_creates_single_driver(
+ self, mock_driver_factory, mock_settings
+ ):
+ """Multiple threads calling init_driver() should create only one driver."""
+ import api.attack_paths.database as db_module
+
+ mock_driver = MagicMock()
+ mock_driver_factory.return_value = mock_driver
+ mock_settings.DATABASES = {
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": 7687,
+ "USER": "neo4j",
+ "PASSWORD": "password",
+ }
+ }
+
+ results = []
+ errors = []
+
+ def call_init():
+ try:
+ result = db_module.init_driver()
+ results.append(result)
+ except Exception as e:
+ errors.append(e)
+
+ threads = [threading.Thread(target=call_init) for _ in range(10)]
+
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert not errors, f"Threads raised errors: {errors}"
+
+ # Only one driver created
+ assert mock_driver_factory.call_count == 1
+
+ # All threads got the same driver instance
+ assert all(r is mock_driver for r in results)
+ assert len(results) == 10
diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py
index 7312335921..8774f33787 100644
--- a/api/src/backend/api/tests/test_compliance.py
+++ b/api/src/backend/api/tests/test_compliance.py
@@ -6,7 +6,6 @@ from api.compliance import (
get_prowler_provider_checks,
get_prowler_provider_compliance,
load_prowler_checks,
- load_prowler_compliance,
)
from api.models import Provider
@@ -35,55 +34,6 @@ class TestCompliance:
assert compliance_data == mock_compliance.get_bulk.return_value
mock_compliance.get_bulk.assert_called_once_with(provider_type)
- @patch("api.models.Provider.ProviderChoices")
- @patch("api.compliance.get_prowler_provider_compliance")
- @patch("api.compliance.generate_compliance_overview_template")
- @patch("api.compliance.load_prowler_checks")
- def test_load_prowler_compliance(
- self,
- mock_load_prowler_checks,
- mock_generate_compliance_overview_template,
- mock_get_prowler_provider_compliance,
- mock_provider_choices,
- ):
- mock_provider_choices.values = ["aws", "azure"]
-
- compliance_data_aws = {"compliance_aws": MagicMock()}
- compliance_data_azure = {"compliance_azure": MagicMock()}
-
- compliance_data_dict = {
- "aws": compliance_data_aws,
- "azure": compliance_data_azure,
- }
-
- def mock_get_compliance(provider_type):
- return compliance_data_dict[provider_type]
-
- mock_get_prowler_provider_compliance.side_effect = mock_get_compliance
-
- mock_generate_compliance_overview_template.return_value = {
- "template_key": "template_value"
- }
-
- mock_load_prowler_checks.return_value = {"checks_key": "checks_value"}
-
- load_prowler_compliance()
-
- from api.compliance import PROWLER_CHECKS, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE
-
- assert PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE == {
- "template_key": "template_value"
- }
- assert PROWLER_CHECKS == {"checks_key": "checks_value"}
-
- expected_prowler_compliance = compliance_data_dict
- mock_get_prowler_provider_compliance.assert_any_call("aws")
- mock_get_prowler_provider_compliance.assert_any_call("azure")
- mock_generate_compliance_overview_template.assert_called_once_with(
- expected_prowler_compliance
- )
- mock_load_prowler_checks.assert_called_once_with(expected_prowler_compliance)
-
@patch("api.compliance.get_prowler_provider_checks")
@patch("api.models.Provider.ProviderChoices")
def test_load_prowler_checks(
diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py
index f2a0c299f2..8d4fdabcbe 100644
--- a/api/src/backend/api/tests/test_views.py
+++ b/api/src/backend/api/tests/test_views.py
@@ -32,6 +32,10 @@ from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.response import Response
+from api.attack_paths import (
+ AttackPathsQueryDefinition,
+ AttackPathsQueryParameterDefinition,
+)
from api.compliance import get_compliance_frameworks
from api.db_router import MainRouter
from api.models import (
@@ -3602,6 +3606,423 @@ class TestTaskViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
+@pytest.mark.django_db
+class TestAttackPathsScanViewSet:
+ @staticmethod
+ def _run_payload(query_id="aws-rds", parameters=None):
+ return {
+ "data": {
+ "type": "attack-paths-query-run-requests",
+ "attributes": {
+ "id": query_id,
+ "parameters": parameters or {},
+ },
+ }
+ }
+
+ def test_attack_paths_scans_list_returns_latest_entry_per_provider(
+ self,
+ authenticated_client,
+ providers_fixture,
+ scans_fixture,
+ create_attack_paths_scan,
+ ):
+ provider = providers_fixture[0]
+ other_provider = providers_fixture[1]
+
+ older_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ state=StateChoices.AVAILABLE,
+ progress=10,
+ )
+ latest_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ state=StateChoices.COMPLETED,
+ progress=95,
+ )
+ other_provider_scan = create_attack_paths_scan(
+ other_provider,
+ scan=scans_fixture[2],
+ state=StateChoices.FAILED,
+ progress=50,
+ )
+
+ response = authenticated_client.get(reverse("attack-paths-scans-list"))
+
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ ids = {item["id"] for item in data}
+ assert ids == {str(latest_scan.id), str(other_provider_scan.id)}
+ assert str(older_scan.id) not in ids
+
+ provider_entry = next(
+ item
+ for item in data
+ if item["relationships"]["provider"]["data"]["id"] == str(provider.id)
+ )
+
+ first_attributes = provider_entry["attributes"]
+ assert first_attributes["provider_alias"] == provider.alias
+ assert first_attributes["provider_type"] == provider.provider
+ assert first_attributes["provider_uid"] == provider.uid
+
+ def test_attack_paths_scans_list_respects_provider_group_visibility(
+ self,
+ authenticated_client_no_permissions_rbac,
+ providers_fixture,
+ create_attack_paths_scan,
+ ):
+ client = authenticated_client_no_permissions_rbac
+ limited_user = client.user
+ membership = Membership.objects.filter(user=limited_user).first()
+ tenant = membership.tenant
+
+ allowed_provider = providers_fixture[0]
+ denied_provider = providers_fixture[1]
+
+ allowed_scan = create_attack_paths_scan(allowed_provider)
+ create_attack_paths_scan(denied_provider)
+
+ provider_group = ProviderGroup.objects.create(
+ name="limited-group",
+ tenant_id=tenant.id,
+ )
+ ProviderGroupMembership.objects.create(
+ tenant_id=tenant.id,
+ provider_group=provider_group,
+ provider=allowed_provider,
+ )
+ limited_role = limited_user.roles.first()
+ RoleProviderGroupRelationship.objects.create(
+ tenant_id=tenant.id,
+ role=limited_role,
+ provider_group=provider_group,
+ )
+
+ response = client.get(reverse("attack-paths-scans-list"))
+
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ assert len(data) == 1
+ assert data[0]["id"] == str(allowed_scan.id)
+
+ def test_attack_paths_scan_retrieve(
+ self,
+ authenticated_client,
+ providers_fixture,
+ scans_fixture,
+ create_attack_paths_scan,
+ ):
+ provider = providers_fixture[0]
+ attack_paths_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ state=StateChoices.COMPLETED,
+ progress=80,
+ )
+
+ response = authenticated_client.get(
+ reverse("attack-paths-scans-detail", kwargs={"pk": attack_paths_scan.id})
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ assert data["id"] == str(attack_paths_scan.id)
+ assert data["relationships"]["provider"]["data"]["id"] == str(provider.id)
+ assert data["attributes"]["state"] == StateChoices.COMPLETED
+
+ def test_attack_paths_scan_retrieve_not_found_for_foreign_tenant(
+ self, authenticated_client, create_attack_paths_scan
+ ):
+ other_tenant = Tenant.objects.create(name="Foreign AttackPaths Tenant")
+ foreign_provider = Provider.objects.create(
+ provider="aws",
+ uid="333333333333",
+ alias="foreign",
+ tenant_id=other_tenant.id,
+ )
+ foreign_scan = create_attack_paths_scan(foreign_provider)
+
+ response = authenticated_client.get(
+ reverse("attack-paths-scans-detail", kwargs={"pk": foreign_scan.id})
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_attack_paths_queries_returns_catalog(
+ self,
+ authenticated_client,
+ providers_fixture,
+ scans_fixture,
+ create_attack_paths_scan,
+ ):
+ provider = providers_fixture[0]
+ attack_paths_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ )
+
+ definitions = [
+ AttackPathsQueryDefinition(
+ id="aws-rds",
+ name="RDS inventory",
+ description="List account RDS assets",
+ provider=provider.provider,
+ cypher="MATCH (n) RETURN n",
+ parameters=[
+ AttackPathsQueryParameterDefinition(name="ip", label="IP address")
+ ],
+ )
+ ]
+
+ with patch(
+ "api.v1.views.get_queries_for_provider", return_value=definitions
+ ) as mock_get_queries:
+ response = authenticated_client.get(
+ reverse(
+ "attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id}
+ )
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ mock_get_queries.assert_called_once_with(provider.provider)
+ payload = response.json()["data"]
+ assert len(payload) == 1
+ assert payload[0]["id"] == "aws-rds"
+ assert payload[0]["attributes"]["name"] == "RDS inventory"
+ assert payload[0]["attributes"]["parameters"][0]["name"] == "ip"
+
+ def test_attack_paths_queries_returns_404_when_catalog_missing(
+ self,
+ authenticated_client,
+ providers_fixture,
+ scans_fixture,
+ create_attack_paths_scan,
+ ):
+ provider = providers_fixture[0]
+ attack_paths_scan = create_attack_paths_scan(provider, scan=scans_fixture[0])
+
+ with patch("api.v1.views.get_queries_for_provider", return_value=[]):
+ response = authenticated_client.get(
+ reverse(
+ "attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id}
+ )
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert "No queries found" in str(response.json())
+
+ def test_run_attack_paths_query_returns_graph(
+ self,
+ authenticated_client,
+ providers_fixture,
+ scans_fixture,
+ create_attack_paths_scan,
+ ):
+ provider = providers_fixture[0]
+ attack_paths_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ graph_database="tenant-db",
+ )
+ query_definition = AttackPathsQueryDefinition(
+ id="aws-rds",
+ name="RDS inventory",
+ description="List account RDS assets",
+ provider=provider.provider,
+ cypher="MATCH (n) RETURN n",
+ parameters=[],
+ )
+ prepared_parameters = {"provider_uid": provider.uid}
+ graph_payload = {
+ "nodes": [
+ {
+ "id": "node-1",
+ "labels": ["AWSAccount"],
+ "properties": {"name": "root"},
+ }
+ ],
+ "relationships": [
+ {
+ "id": "rel-1",
+ "label": "OWNS",
+ "source": "node-1",
+ "target": "node-2",
+ "properties": {},
+ }
+ ],
+ }
+
+ with (
+ patch(
+ "api.v1.views.get_query_by_id", return_value=query_definition
+ ) as mock_get_query,
+ patch(
+ "api.v1.views.attack_paths_views_helpers.prepare_query_parameters",
+ return_value=prepared_parameters,
+ ) as mock_prepare,
+ patch(
+ "api.v1.views.attack_paths_views_helpers.execute_attack_paths_query",
+ return_value=graph_payload,
+ ) as mock_execute,
+ patch("api.v1.views.graph_database.clear_cache") as mock_clear_cache,
+ ):
+ response = authenticated_client.post(
+ reverse(
+ "attack-paths-scans-queries-run",
+ kwargs={"pk": attack_paths_scan.id},
+ ),
+ data=self._run_payload("aws-rds"),
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ mock_get_query.assert_called_once_with("aws-rds")
+ mock_prepare.assert_called_once_with(
+ query_definition,
+ {},
+ attack_paths_scan.provider.uid,
+ )
+ mock_execute.assert_called_once_with(
+ attack_paths_scan,
+ query_definition,
+ prepared_parameters,
+ )
+ mock_clear_cache.assert_called_once_with(attack_paths_scan.graph_database)
+ result = response.json()["data"]
+ attributes = result["attributes"]
+ assert attributes["nodes"] == graph_payload["nodes"]
+ assert attributes["relationships"] == graph_payload["relationships"]
+
+ def test_run_attack_paths_query_requires_completed_scan(
+ self,
+ authenticated_client,
+ providers_fixture,
+ scans_fixture,
+ create_attack_paths_scan,
+ ):
+ provider = providers_fixture[0]
+ attack_paths_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ state=StateChoices.EXECUTING,
+ )
+
+ response = authenticated_client.post(
+ reverse(
+ "attack-paths-scans-queries-run", kwargs={"pk": attack_paths_scan.id}
+ ),
+ data=self._run_payload(),
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "must be completed" in response.json()["errors"][0]["detail"]
+
+ def test_run_attack_paths_query_requires_graph_database(
+ self,
+ authenticated_client,
+ providers_fixture,
+ scans_fixture,
+ create_attack_paths_scan,
+ ):
+ provider = providers_fixture[0]
+ attack_paths_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ graph_database=None,
+ )
+
+ response = authenticated_client.post(
+ reverse(
+ "attack-paths-scans-queries-run", kwargs={"pk": attack_paths_scan.id}
+ ),
+ data=self._run_payload(),
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+
+ assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+ assert "does not reference a graph database" in str(response.json())
+
+ def test_run_attack_paths_query_unknown_query(
+ self,
+ authenticated_client,
+ providers_fixture,
+ scans_fixture,
+ create_attack_paths_scan,
+ ):
+ provider = providers_fixture[0]
+ attack_paths_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ )
+
+ with patch("api.v1.views.get_query_by_id", return_value=None):
+ response = authenticated_client.post(
+ reverse(
+ "attack-paths-scans-queries-run",
+ kwargs={"pk": attack_paths_scan.id},
+ ),
+ data=self._run_payload("unknown-query"),
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "Unknown Attack Paths query" in response.json()["errors"][0]["detail"]
+
+ def test_run_attack_paths_query_returns_404_when_no_nodes_found(
+ self,
+ authenticated_client,
+ providers_fixture,
+ scans_fixture,
+ create_attack_paths_scan,
+ ):
+ provider = providers_fixture[0]
+ attack_paths_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ )
+ query_definition = AttackPathsQueryDefinition(
+ id="aws-empty",
+ name="empty",
+ description="",
+ provider=provider.provider,
+ cypher="MATCH (n) RETURN n",
+ )
+
+ with (
+ patch("api.v1.views.get_query_by_id", return_value=query_definition),
+ patch(
+ "api.v1.views.attack_paths_views_helpers.prepare_query_parameters",
+ return_value={"provider_uid": provider.uid},
+ ),
+ patch(
+ "api.v1.views.attack_paths_views_helpers.execute_attack_paths_query",
+ return_value={"nodes": [], "relationships": []},
+ ),
+ patch("api.v1.views.graph_database.clear_cache"),
+ ):
+ response = authenticated_client.post(
+ reverse(
+ "attack-paths-scans-queries-run",
+ kwargs={"pk": attack_paths_scan.id},
+ ),
+ data=self._run_payload("aws-empty"),
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ payload = response.json()
+ if "data" in payload:
+ attributes = payload["data"].get("attributes", {})
+ assert attributes.get("nodes") == []
+ assert attributes.get("relationships") == []
+ else:
+ assert "errors" in payload
+
+
@pytest.mark.django_db
class TestResourceViewSet:
def test_resources_list_none(self, authenticated_client):
@@ -3625,6 +4046,7 @@ class TestResourceViewSet:
assert "metadata" in response.json()["data"][0]["attributes"]
assert "details" in response.json()["data"][0]["attributes"]
assert "partition" in response.json()["data"][0]["attributes"]
+ assert "groups" in response.json()["data"][0]["attributes"]
@pytest.mark.parametrize(
"include_values, expected_resources",
@@ -3699,6 +4121,10 @@ class TestResourceViewSet:
# full text search on resource tags
("search", "multi word", 1),
("search", "key2", 2),
+ # groups filter (ArrayField)
+ ("groups", "compute", 2),
+ ("groups", "storage", 1),
+ ("groups.in", "compute,storage", 3),
]
),
)
@@ -3845,12 +4271,14 @@ class TestResourceViewSet:
expected_services = {"ec2", "s3"}
expected_regions = {"us-east-1", "eu-west-1"}
expected_resource_types = {"prowler-test"}
+ expected_groups = {"compute", "storage"}
assert data["data"]["type"] == "resources-metadata"
assert data["data"]["id"] is None
assert set(data["data"]["attributes"]["services"]) == expected_services
assert set(data["data"]["attributes"]["regions"]) == expected_regions
assert set(data["data"]["attributes"]["types"]) == expected_resource_types
+ assert set(data["data"]["attributes"]["groups"]) == expected_groups
def test_resources_metadata_resource_filter_retrieve(
self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture
@@ -3886,6 +4314,7 @@ class TestResourceViewSet:
assert data["data"]["attributes"]["services"] == []
assert data["data"]["attributes"]["regions"] == []
assert data["data"]["attributes"]["types"] == []
+ assert data["data"]["attributes"]["groups"] == []
def test_resources_metadata_invalid_date(self, authenticated_client):
response = authenticated_client.get(
@@ -3925,6 +4354,826 @@ class TestResourceViewSet:
assert attributes["services"] == [latest_scan_resource.service]
assert attributes["regions"] == [latest_scan_resource.region]
assert attributes["types"] == [latest_scan_resource.type]
+ assert "groups" in attributes
+
+ def test_resources_latest_filter_by_provider_id(
+ self, authenticated_client, latest_scan_resource
+ ):
+ """Test that provider_id filter works on latest resources endpoint."""
+ provider = latest_scan_resource.provider
+ response = authenticated_client.get(
+ reverse("resource-latest"),
+ {"filter[provider_id]": str(provider.id)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 1
+ assert (
+ response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
+ )
+
+ def test_resources_latest_filter_by_provider_id_in(
+ self, authenticated_client, latest_scan_resource
+ ):
+ """Test that provider_id__in filter works on latest resources endpoint."""
+ provider = latest_scan_resource.provider
+ response = authenticated_client.get(
+ reverse("resource-latest"),
+ {"filter[provider_id__in]": str(provider.id)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 1
+ assert (
+ response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
+ )
+
+ def test_resources_latest_filter_by_provider_id_in_multiple(
+ self, authenticated_client, providers_fixture
+ ):
+ """Test that provider_id__in filter works with multiple provider IDs."""
+ provider1, provider2 = providers_fixture[0], providers_fixture[1]
+ tenant_id = str(provider1.tenant_id)
+
+ # Create completed scans for both providers
+ Scan.objects.create(
+ name="scan for provider 1",
+ provider=provider1,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant_id,
+ )
+ Scan.objects.create(
+ name="scan for provider 2",
+ provider=provider2,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant_id,
+ )
+
+ # Create resources for each provider
+ resource1 = Resource.objects.create(
+ tenant_id=tenant_id,
+ provider=provider1,
+ uid="resource_provider_1",
+ name="Resource Provider 1",
+ region="us-east-1",
+ service="ec2",
+ type="instance",
+ )
+ Resource.objects.create(
+ tenant_id=tenant_id,
+ provider=provider2,
+ uid="resource_provider_2",
+ name="Resource Provider 2",
+ region="us-west-2",
+ service="s3",
+ type="bucket",
+ )
+
+ # Test filtering by both providers
+ response = authenticated_client.get(
+ reverse("resource-latest"),
+ {"filter[provider_id__in]": f"{provider1.id},{provider2.id}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 2
+
+ # Test filtering by single provider returns only that provider's resource
+ response = authenticated_client.get(
+ reverse("resource-latest"),
+ {"filter[provider_id__in]": str(provider1.id)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 1
+ assert response.json()["data"][0]["attributes"]["uid"] == resource1.uid
+
+ def test_resources_latest_filter_by_provider_id_no_match(
+ self, authenticated_client, latest_scan_resource
+ ):
+ """Test that provider_id filter returns empty when no match."""
+ non_existent_id = str(uuid4())
+ response = authenticated_client.get(
+ reverse("resource-latest"),
+ {"filter[provider_id]": non_existent_id},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 0
+
+ # Events endpoint tests
+ def test_events_non_aws_provider(self, authenticated_client, providers_fixture):
+ """Test events endpoint rejects non-AWS providers."""
+ from api.models import Resource
+
+ azure_provider = providers_fixture[4] # Azure provider from fixture
+
+ resource = Resource.objects.create(
+ uid="test-resource-id",
+ name="Test Resource",
+ type="test-type",
+ region="us-east-1",
+ service="test-service",
+ provider=azure_provider,
+ tenant_id=azure_provider.tenant_id,
+ )
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id})
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ # Verify JSON:API error structure
+ error = response.json()["errors"][0]
+ assert error["code"] == "invalid_provider"
+ assert error["status"] == "400" # Must be string per JSON:API spec
+ assert error["source"]["pointer"] == "/data/attributes/provider"
+ assert "AWS" in error["detail"]
+
+ @pytest.mark.parametrize(
+ "lookback_days,expected_status,expected_code,expected_detail_contains",
+ [
+ ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"),
+ ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
+ ("91", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
+ ("-5", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
+ ],
+ )
+ def test_events_invalid_lookback_days(
+ self,
+ authenticated_client,
+ providers_fixture,
+ lookback_days,
+ expected_status,
+ expected_code,
+ expected_detail_contains,
+ ):
+ """Test events endpoint validates lookback_days with JSON:API compliant errors."""
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ resource = Resource.objects.create(
+ uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
+ name="Test Instance",
+ type="instance",
+ region="us-east-1",
+ service="ec2",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id}),
+ {"lookback_days": lookback_days},
+ )
+
+ assert response.status_code == expected_status
+
+ # Verify JSON:API error structure
+ error = response.json()["errors"][0]
+ assert error["code"] == expected_code
+ assert error["status"] == "400" # Must be string per JSON:API spec
+ assert error["source"]["parameter"] == "lookback_days"
+ assert expected_detail_contains in error["detail"]
+
+ @pytest.mark.parametrize(
+ "page_size,expected_status,expected_code,expected_detail_contains",
+ [
+ ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"),
+ ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
+ ("51", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
+ ("-1", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
+ ],
+ )
+ def test_events_invalid_page_size(
+ self,
+ authenticated_client,
+ providers_fixture,
+ page_size,
+ expected_status,
+ expected_code,
+ expected_detail_contains,
+ ):
+ """Test events endpoint validates page[size] with JSON:API compliant errors."""
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ resource = Resource.objects.create(
+ uid="arn:aws:ec2:us-east-1:123456789012:instance/i-pagesize-test",
+ name="Test Instance",
+ type="instance",
+ region="us-east-1",
+ service="ec2",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id}),
+ {"page[size]": page_size},
+ )
+
+ assert response.status_code == expected_status
+
+ # Verify JSON:API error structure
+ error = response.json()["errors"][0]
+ assert error["code"] == expected_code
+ assert error["status"] == "400" # Must be string per JSON:API spec
+ assert error["source"]["parameter"] == "page[size]"
+ assert expected_detail_contains in error["detail"]
+
+ @pytest.mark.parametrize(
+ "invalid_params,expected_invalid_param",
+ [
+ ({"filter[service]": "ec2"}, "filter[service]"),
+ ({"filter[region]": "us-east-1"}, "filter[region]"),
+ ({"sort": "-name"}, "sort"),
+ ({"unknown_param": "value"}, "unknown_param"),
+ ({"filter[servic]": "ec2"}, "filter[servic]"), # Typo in filter name
+ ],
+ )
+ def test_events_invalid_query_parameter(
+ self,
+ authenticated_client,
+ providers_fixture,
+ invalid_params,
+ expected_invalid_param,
+ ):
+ """Test events endpoint rejects unknown query parameters with JSON:API compliant errors."""
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ resource = Resource.objects.create(
+ uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
+ name="Test Instance",
+ type="instance",
+ region="us-east-1",
+ service="ec2",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id}),
+ invalid_params,
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ # Verify JSON:API error structure
+ errors = response.json()["errors"]
+ assert len(errors) >= 1
+
+ # Find the error for our expected invalid param
+ error = next(
+ (e for e in errors if e["source"]["parameter"] == expected_invalid_param),
+ None,
+ )
+ assert (
+ error is not None
+ ), f"Expected error for parameter '{expected_invalid_param}'"
+ assert error["code"] == "invalid"
+ assert error["status"] == "400" # Must be string per JSON:API spec
+ assert expected_invalid_param in error["detail"]
+
+ def test_events_multiple_invalid_query_parameters(
+ self,
+ authenticated_client,
+ providers_fixture,
+ ):
+ """Test events endpoint returns error for first unknown parameter."""
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0]
+
+ resource = Resource.objects.create(
+ uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
+ name="Test Instance",
+ type="instance",
+ region="us-east-1",
+ service="ec2",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ # Send multiple invalid parameters - only first one triggers error
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id}),
+ {"filter[service]": "ec2", "sort": "-name", "unknown": "value"},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ # Should have one error for the first invalid parameter encountered
+ errors = response.json()["errors"]
+ assert len(errors) == 1
+ assert errors[0]["code"] == "invalid"
+ assert errors[0]["status"] == "400"
+ assert errors[0]["source"]["parameter"] in {
+ "filter[service]",
+ "sort",
+ "unknown",
+ }
+
+ @patch("api.v1.views.initialize_prowler_provider")
+ @patch("api.v1.views.CloudTrailTimeline")
+ def test_events_success(
+ self,
+ mock_cloudtrail_timeline,
+ mock_initialize_provider,
+ authenticated_client,
+ providers_fixture,
+ ):
+ """Test successful events retrieval."""
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ # Create test resource
+ resource = Resource.objects.create(
+ uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test123",
+ name="Test EC2 Instance",
+ type="instance",
+ region="us-east-1",
+ service="ec2",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ # Mock provider session
+ mock_session = Mock()
+ mock_provider = Mock()
+ mock_provider._session.current_session = mock_session
+ mock_initialize_provider.return_value = mock_provider
+
+ # Mock CloudTrail timeline response - events need event_id for serializer
+ mock_timeline_instance = Mock()
+ mock_events = [
+ {
+ "event_id": "event-1-id",
+ "event_time": "2024-01-15T10:30:00Z",
+ "event_name": "RunInstances",
+ "event_source": "ec2.amazonaws.com",
+ "actor": "admin@example.com",
+ "actor_type": "IAMUser",
+ "source_ip_address": "203.0.113.1",
+ "user_agent": "aws-cli/2.0.0",
+ },
+ {
+ "event_id": "event-2-id",
+ "event_time": "2024-01-16T14:20:00Z",
+ "event_name": "StopInstances",
+ "event_source": "ec2.amazonaws.com",
+ "actor": "operator@example.com",
+ "actor_type": "IAMUser",
+ },
+ ]
+ mock_timeline_instance.get_resource_timeline.return_value = mock_events
+ mock_cloudtrail_timeline.return_value = mock_timeline_instance
+
+ # Make request with lookback_days parameter
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id}),
+ {"lookback_days": "30"},
+ )
+
+ # Assertions - response is wrapped by JSON:API renderer
+ assert response.status_code == status.HTTP_200_OK
+ response_data = response.json()
+ events = response_data["data"]
+
+ assert len(events) == 2
+
+ # Verify JSON:API structure: type and id are present
+ assert events[0]["type"] == "resource-events"
+ assert events[0]["id"] == "event-1-id"
+ assert events[1]["type"] == "resource-events"
+ assert events[1]["id"] == "event-2-id"
+
+ # Verify attributes
+ assert events[0]["attributes"]["event_name"] == "RunInstances"
+ assert events[0]["attributes"]["actor"] == "admin@example.com"
+ assert events[1]["attributes"]["event_name"] == "StopInstances"
+
+ # Verify CloudTrail was called with correct parameters
+ mock_cloudtrail_timeline.assert_called_once_with(
+ session=mock_session,
+ lookback_days=30,
+ max_results=50, # Default page size
+ write_events_only=True, # Default: exclude read events
+ )
+ mock_timeline_instance.get_resource_timeline.assert_called_once_with(
+ region=resource.region,
+ resource_uid=resource.uid,
+ )
+
+ @patch("api.v1.views.initialize_prowler_provider")
+ @patch("api.v1.views.CloudTrailTimeline")
+ def test_events_default_lookback_days(
+ self,
+ mock_cloudtrail_timeline,
+ mock_initialize_provider,
+ authenticated_client,
+ providers_fixture,
+ ):
+ """Test events uses default lookback_days (90) when not provided."""
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ resource = Resource.objects.create(
+ uid="arn:aws:s3:::test-bucket",
+ name="Test Bucket",
+ type="bucket",
+ region="us-east-1",
+ service="s3",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ # Mock provider session
+ mock_session = Mock()
+ mock_provider = Mock()
+ mock_provider._session.current_session = mock_session
+ mock_initialize_provider.return_value = mock_provider
+
+ # Mock CloudTrail timeline response
+ mock_timeline_instance = Mock()
+ mock_timeline_instance.get_resource_timeline.return_value = []
+ mock_cloudtrail_timeline.return_value = mock_timeline_instance
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id})
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+
+ # Verify default lookback_days (90) was used
+ mock_cloudtrail_timeline.assert_called_once_with(
+ session=mock_session,
+ lookback_days=90, # Default
+ max_results=50,
+ write_events_only=True,
+ )
+
+ @patch("api.v1.views.initialize_prowler_provider")
+ def test_events_no_credentials_error(
+ self, mock_initialize_provider, authenticated_client, providers_fixture
+ ):
+ """Test events handles missing credentials errors."""
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ resource = Resource.objects.create(
+ uid="arn:aws:rds:us-west-2:123456789012:db:test-db",
+ name="Test Database",
+ type="db-instance",
+ region="us-west-2",
+ service="rds",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ mock_initialize_provider.side_effect = NoCredentialsError()
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id})
+ )
+
+ # 502 because this is an upstream auth failure, not API auth failure
+ assert response.status_code == status.HTTP_502_BAD_GATEWAY
+
+ # Verify JSON:API error structure
+ error = response.json()["errors"][0]
+ assert error["code"] == "upstream_auth_failed"
+ assert error["status"] == "502" # Must be string per JSON:API spec
+ assert "detail" in error
+
+ @patch("api.v1.views.initialize_prowler_provider")
+ @patch("api.v1.views.CloudTrailTimeline")
+ def test_events_access_denied_error(
+ self,
+ mock_cloudtrail_timeline,
+ mock_initialize_provider,
+ authenticated_client,
+ providers_fixture,
+ ):
+ """Test events handles AccessDenied errors from AWS."""
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ resource = Resource.objects.create(
+ uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func",
+ name="Test Function",
+ type="function",
+ region="eu-west-1",
+ service="lambda",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ # Mock provider
+ mock_session = Mock()
+ mock_provider = Mock()
+ mock_provider._session.current_session = mock_session
+ mock_initialize_provider.return_value = mock_provider
+
+ # Mock ClientError with AccessDenied
+ mock_timeline_instance = Mock()
+ mock_timeline_instance.get_resource_timeline.side_effect = ClientError(
+ {"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
+ "LookupEvents",
+ )
+ mock_cloudtrail_timeline.return_value = mock_timeline_instance
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id})
+ )
+
+ # AccessDenied returns 502 (upstream error, not user's fault)
+ assert response.status_code == status.HTTP_502_BAD_GATEWAY
+
+ # Verify JSON:API error structure
+ error = response.json()["errors"][0]
+ assert error["code"] == "upstream_access_denied"
+ assert error["status"] == "502" # Must be string per JSON:API spec
+ assert "detail" in error
+
+ @patch("api.v1.views.initialize_prowler_provider")
+ @patch("api.v1.views.CloudTrailTimeline")
+ def test_events_service_unavailable_error(
+ self,
+ mock_cloudtrail_timeline,
+ mock_initialize_provider,
+ authenticated_client,
+ providers_fixture,
+ ):
+ """Test events handles generic AWS API errors as 503."""
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ resource = Resource.objects.create(
+ uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func2",
+ name="Test Function 2",
+ type="function",
+ region="eu-west-1",
+ service="lambda",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ # Mock provider
+ mock_session = Mock()
+ mock_provider = Mock()
+ mock_provider._session.current_session = mock_session
+ mock_initialize_provider.return_value = mock_provider
+
+ # Mock ClientError with non-AccessDenied error
+ mock_timeline_instance = Mock()
+ mock_timeline_instance.get_resource_timeline.side_effect = ClientError(
+ {"Error": {"Code": "ServiceUnavailable", "Message": "Service unavailable"}},
+ "LookupEvents",
+ )
+ mock_cloudtrail_timeline.return_value = mock_timeline_instance
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id})
+ )
+
+ # Non-AccessDenied errors return 503
+ assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
+
+ # Verify JSON:API error structure
+ error = response.json()["errors"][0]
+ assert error["code"] == "service_unavailable"
+ assert error["status"] == "503" # Must be string per JSON:API spec
+ assert "detail" in error
+
+ @patch("api.v1.views.initialize_prowler_provider")
+ def test_events_assume_role_access_denied(
+ self,
+ mock_initialize_provider,
+ authenticated_client,
+ providers_fixture,
+ ):
+ """Test events handles AWSAssumeRoleError during provider init.
+
+ This tests the scenario from CLOUD-API-3HJ where the API task role
+ cannot assume the customer's ProwlerScan role due to IAM permissions.
+ The error happens during initialize_prowler_provider, which wraps
+ the ClientError in AWSAssumeRoleError.
+ """
+ from api.models import Resource
+ from prowler.providers.aws.exceptions.exceptions import AWSAssumeRoleError
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ resource = Resource.objects.create(
+ uid="arn:aws:lambda:eu-west-1:123456789012:function:assume-role-test",
+ name="AssumeRole Test Function",
+ type="function",
+ region="eu-west-1",
+ service="lambda",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ # Mock initialize_prowler_provider raising AWSAssumeRoleError
+ # (this is what aws_provider.py actually raises when AssumeRole fails)
+ original_error = ClientError(
+ {
+ "Error": {
+ "Code": "AccessDenied",
+ "Message": (
+ "User: arn:aws:sts::123456789012:assumed-role/api-task-role/xxx "
+ "is not authorized to perform: sts:AssumeRole on resource: "
+ "arn:aws:iam::123456789012:role/ProwlerScan"
+ ),
+ }
+ },
+ "AssumeRole",
+ )
+ mock_initialize_provider.side_effect = AWSAssumeRoleError(
+ original_exception=original_error,
+ file="aws_provider.py",
+ )
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id})
+ )
+
+ # AWSAssumeRoleError returns 502 (upstream auth failure)
+ assert response.status_code == status.HTTP_502_BAD_GATEWAY
+
+ # Verify JSON:API error structure
+ error = response.json()["errors"][0]
+ assert error["code"] == "upstream_access_denied"
+ assert error["status"] == "502"
+ assert "detail" in error
+
+ def test_events_unauthenticated_returns_401(self, providers_fixture):
+ """Test events endpoint returns 401 when no credentials are provided.
+
+ This ensures the endpoint follows API conventions where missing authentication
+ returns 401 Unauthorized, not 404 Not Found.
+ """
+ from rest_framework.test import APIClient
+
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0] # AWS provider from fixture
+
+ resource = Resource.objects.create(
+ uid="arn:aws:ec2:us-east-1:123456789012:instance/i-unauth-test",
+ name="Test Instance",
+ type="instance",
+ region="us-east-1",
+ service="ec2",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ # Use unauthenticated client (no JWT token)
+ unauthenticated_client = APIClient()
+
+ response = unauthenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id})
+ )
+
+ # Must return 401 Unauthorized, not 404 Not Found
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
+ f"Expected 401 Unauthorized but got {response.status_code}. "
+ "Unauthenticated requests should return 401, not 404."
+ )
+
+ def test_events_cross_tenant_returns_404(
+ self, authenticated_client, tenants_fixture
+ ):
+ """Test events endpoint returns 404 for resources in other tenants (RLS).
+
+ Users cannot access resources belonging to other tenants due to
+ Row-Level Security. The resource should appear to not exist.
+ """
+ from api.models import Provider, Resource
+
+ # tenant3 (tenants_fixture[2]) has no membership for the test user
+ isolated_tenant = tenants_fixture[2]
+
+ # Create provider in the isolated tenant
+ other_tenant_provider = Provider.objects.create(
+ provider="aws",
+ uid="999999999999",
+ alias="other_tenant_aws",
+ tenant_id=isolated_tenant.id,
+ )
+
+ # Create resource in the OTHER tenant (not the authenticated user's tenant)
+ resource = Resource.objects.create(
+ uid="arn:aws:ec2:us-east-1:999999999999:instance/i-other-tenant",
+ name="Other Tenant Resource",
+ type="instance",
+ region="us-east-1",
+ service="ec2",
+ provider=other_tenant_provider,
+ tenant_id=isolated_tenant.id,
+ )
+
+ response = authenticated_client.get(
+ reverse("resource-events", kwargs={"pk": resource.id})
+ )
+
+ # RLS hides resources from other tenants - should appear as not found
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_events_expired_token_returns_401(self, providers_fixture, tenants_fixture):
+ """Test events endpoint returns 401 when JWT token is expired.
+
+ Expired tokens should return 401 Unauthorized, not 404 Not Found.
+ This ensures authentication errors are properly distinguished from
+ resource not found errors.
+ """
+ from rest_framework.test import APIClient
+
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0]
+
+ resource = Resource.objects.create(
+ uid="arn:aws:ec2:us-east-1:123456789012:instance/i-expired-test",
+ name="Test Instance",
+ type="instance",
+ region="us-east-1",
+ service="ec2",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ # Create an expired JWT token
+ tenant = tenants_fixture[0]
+ expired_payload = {
+ "token_type": "access",
+ "exp": datetime.now(timezone.utc)
+ - timedelta(hours=1), # Expired 1 hour ago
+ "iat": datetime.now(timezone.utc) - timedelta(hours=2),
+ "jti": str(uuid4()),
+ "user_id": str(uuid4()),
+ "tenant_id": str(tenant.id),
+ }
+ expired_token = jwt.encode(
+ expired_payload, settings.SECRET_KEY, algorithm="HS256"
+ )
+
+ client = APIClient()
+ client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")
+
+ response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
+
+ # Must return 401 Unauthorized, not 404 Not Found
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
+ f"Expected 401 Unauthorized but got {response.status_code}. "
+ "Expired tokens should return 401, not 404."
+ )
+
+ def test_events_invalid_token_returns_401(self, providers_fixture):
+ """Test events endpoint returns 401 when JWT token is completely invalid.
+
+ Malformed or invalid tokens should return 401 Unauthorized, not 404 Not Found.
+ """
+ from rest_framework.test import APIClient
+
+ from api.models import Resource
+
+ aws_provider = providers_fixture[0]
+
+ resource = Resource.objects.create(
+ uid="arn:aws:ec2:us-east-1:123456789012:instance/i-invalid-test",
+ name="Test Instance",
+ type="instance",
+ region="us-east-1",
+ service="ec2",
+ provider=aws_provider,
+ tenant_id=aws_provider.tenant_id,
+ )
+
+ client = APIClient()
+
+ # Test with completely malformed token
+ client.credentials(HTTP_AUTHORIZATION="Bearer not.a.valid.jwt.token")
+ response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
+ assert (
+ response.status_code == status.HTTP_401_UNAUTHORIZED
+ ), f"Expected 401 for malformed token but got {response.status_code}"
+
+ # Test with empty bearer token
+ client.credentials(HTTP_AUTHORIZATION="Bearer ")
+ response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
+ assert (
+ response.status_code == status.HTTP_401_UNAUTHORIZED
+ ), f"Expected 401 for empty bearer token but got {response.status_code}"
@pytest.mark.django_db
@@ -4417,6 +5666,17 @@ class TestFindingViewSet:
attributes = response.json()["data"]["attributes"]
assert set(attributes["categories"]) == {"gen-ai", "iam"}
+ def test_findings_metadata_latest_groups(
+ self, authenticated_client, latest_scan_finding_with_categories
+ ):
+ response = authenticated_client.get(
+ reverse("finding-metadata_latest"),
+ )
+ assert response.status_code == status.HTTP_200_OK
+ attributes = response.json()["data"]["attributes"]
+ assert "groups" in attributes
+ assert "ai_ml" in attributes["groups"]
+
def test_findings_filter_by_category(
self, authenticated_client, findings_with_categories
):
@@ -4463,6 +5723,49 @@ class TestFindingViewSet:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 0
+ def test_findings_filter_by_resource_groups(
+ self, authenticated_client, findings_with_group
+ ):
+ finding = findings_with_group
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {
+ "filter[resource_groups]": "storage",
+ "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"),
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 1
+ assert response.json()["data"][0]["attributes"]["resource_groups"] == "storage"
+
+ def test_findings_filter_by_resource_groups_in(
+ self, authenticated_client, findings_with_multiple_groups
+ ):
+ finding1, _ = findings_with_multiple_groups
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {
+ "filter[resource_groups__in]": "storage,security",
+ "filter[inserted_at]": finding1.inserted_at.strftime("%Y-%m-%d"),
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 2
+
+ def test_findings_filter_by_resource_groups_no_match(
+ self, authenticated_client, findings_with_group
+ ):
+ finding = findings_with_group
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {
+ "filter[resource_groups]": "nonexistent",
+ "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"),
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 0
+
@pytest.mark.django_db
class TestJWTFields:
@@ -8009,6 +9312,228 @@ class TestOverviewViewSet:
assert data[0]["attributes"]["failed_findings"] == 13
assert data[0]["attributes"]["new_failed_findings"] == 5
+ def test_overview_groups_no_data(self, authenticated_client):
+ response = authenticated_client.get(reverse("overview-resource-groups"))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["data"] == []
+
+ def test_overview_groups_aggregates_by_group_with_severity(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ providers_fixture,
+ create_scan_resource_group_summary,
+ ):
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+
+ scan = Scan.objects.create(
+ name="resource-groups-scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant=tenant,
+ )
+
+ # resources_count is group-level (same for all severities within a group)
+ create_scan_resource_group_summary(
+ tenant,
+ scan,
+ "storage",
+ "high",
+ total_findings=20,
+ failed_findings=10,
+ new_failed_findings=5,
+ resources_count=8,
+ )
+ create_scan_resource_group_summary(
+ tenant,
+ scan,
+ "storage",
+ "medium",
+ total_findings=15,
+ failed_findings=7,
+ new_failed_findings=3,
+ resources_count=8, # Same as high - group-level count
+ )
+ create_scan_resource_group_summary(
+ tenant,
+ scan,
+ "security",
+ "critical",
+ total_findings=10,
+ failed_findings=8,
+ new_failed_findings=2,
+ resources_count=4,
+ )
+
+ response = authenticated_client.get(reverse("overview-resource-groups"))
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ assert len(data) == 2
+
+ storage_data = next(d for d in data if d["id"] == "storage")
+ security_data = next(d for d in data if d["id"] == "security")
+
+ assert storage_data["attributes"]["total_findings"] == 35
+ assert storage_data["attributes"]["failed_findings"] == 17
+ assert storage_data["attributes"]["new_failed_findings"] == 8
+ assert (
+ storage_data["attributes"]["resources_count"] == 8
+ ) # Group-level, not sum
+ assert security_data["attributes"]["total_findings"] == 10
+ assert security_data["attributes"]["failed_findings"] == 8
+ assert security_data["attributes"]["resources_count"] == 4
+
+ @pytest.mark.parametrize(
+ "filter_key,filter_value_fn,expected_total,expected_failed",
+ [
+ ("filter[provider_id]", lambda p1, p2: str(p1.id), 10, 5),
+ ("filter[provider_id__in]", lambda p1, p2: f"{p1.id},{p2.id}", 25, 12),
+ ("filter[provider_type]", lambda p1, p2: "aws", 10, 5),
+ ("filter[provider_type__in]", lambda p1, p2: "aws,gcp", 25, 12),
+ ],
+ )
+ def test_overview_groups_provider_filters(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ providers_fixture,
+ create_scan_resource_group_summary,
+ filter_key,
+ filter_value_fn,
+ expected_total,
+ expected_failed,
+ ):
+ tenant = tenants_fixture[0]
+ provider1 = providers_fixture[0] # AWS
+ gcp_provider = providers_fixture[2] # GCP
+
+ scan1 = Scan.objects.create(
+ name="aws-rg-scan",
+ provider=provider1,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant=tenant,
+ )
+ scan2 = Scan.objects.create(
+ name="gcp-rg-scan",
+ provider=gcp_provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant=tenant,
+ )
+
+ create_scan_resource_group_summary(
+ tenant, scan1, "storage", "high", total_findings=10, failed_findings=5
+ )
+ create_scan_resource_group_summary(
+ tenant, scan2, "storage", "high", total_findings=15, failed_findings=7
+ )
+
+ response = authenticated_client.get(
+ reverse("overview-resource-groups"),
+ {filter_key: filter_value_fn(provider1, gcp_provider)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ assert len(data) == 1
+ assert data[0]["attributes"]["total_findings"] == expected_total
+ assert data[0]["attributes"]["failed_findings"] == expected_failed
+
+ def test_overview_groups_group_filter(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ providers_fixture,
+ create_scan_resource_group_summary,
+ ):
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+
+ scan = Scan.objects.create(
+ name="rg-filter-scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant=tenant,
+ )
+
+ create_scan_resource_group_summary(
+ tenant, scan, "storage", "high", total_findings=10, failed_findings=5
+ )
+ create_scan_resource_group_summary(
+ tenant, scan, "compute", "medium", total_findings=20, failed_findings=8
+ )
+ create_scan_resource_group_summary(
+ tenant, scan, "security", "low", total_findings=15, failed_findings=3
+ )
+
+ response = authenticated_client.get(
+ reverse("overview-resource-groups"),
+ {"filter[resource_group__in]": "storage,compute"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ group_ids = {item["id"] for item in data}
+ assert group_ids == {"storage", "compute"}
+
+ def test_overview_groups_aggregates_multiple_providers(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ providers_fixture,
+ create_scan_resource_group_summary,
+ ):
+ tenant = tenants_fixture[0]
+ provider1, provider2, *_ = providers_fixture
+
+ scan1 = Scan.objects.create(
+ name="multi-provider-rg-scan-1",
+ provider=provider1,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant=tenant,
+ )
+ scan2 = Scan.objects.create(
+ name="multi-provider-rg-scan-2",
+ provider=provider2,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant=tenant,
+ )
+
+ create_scan_resource_group_summary(
+ tenant,
+ scan1,
+ "storage",
+ "high",
+ total_findings=10,
+ failed_findings=5,
+ new_failed_findings=2,
+ resources_count=4,
+ )
+ create_scan_resource_group_summary(
+ tenant,
+ scan2,
+ "storage",
+ "high",
+ total_findings=15,
+ failed_findings=8,
+ new_failed_findings=3,
+ resources_count=6,
+ )
+
+ response = authenticated_client.get(reverse("overview-resource-groups"))
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ assert len(data) == 1
+ assert data[0]["id"] == "storage"
+ assert data[0]["attributes"]["total_findings"] == 25
+ assert data[0]["attributes"]["failed_findings"] == 13
+ assert data[0]["attributes"]["new_failed_findings"] == 5
+ assert data[0]["attributes"]["resources_count"] == 10
+
def test_compliance_watchlist_no_filters_uses_tenant_summary(
self, authenticated_client, tenant_compliance_summary_fixture
):
@@ -9445,7 +10970,7 @@ class TestLighthouseConfigViewSet:
"type": "lighthouse-configurations",
"attributes": {
"name": "OpenAI",
- "api_key": "sk-test1234567890T3BlbkFJtest1234567890",
+ "api_key": "sk-fake-test-key-for-unit-testing-only",
"model": "gpt-4o",
"temperature": 0.7,
"max_tokens": 4000,
@@ -10907,7 +12432,7 @@ class TestLighthouseTenantConfigViewSet:
provider_config = LighthouseProviderConfiguration.objects.create(
tenant_id=tenants_fixture[0].id,
provider_type="openai",
- credentials=b'{"api_key": "sk-test1234567890T3BlbkFJtest1234567890"}',
+ credentials=b'{"api_key": "sk-fake-test-key-for-unit-testing-only"}',
is_active=True,
)
@@ -11043,7 +12568,7 @@ class TestLighthouseProviderConfigViewSet:
"type": "lighthouse-providers",
"attributes": {
"provider_type": "testprovider",
- "credentials": {"api_key": "sk-testT3BlbkFJkey"},
+ "credentials": {"api_key": "sk-fake-test-key-1234"},
},
}
}
@@ -11075,7 +12600,7 @@ class TestLighthouseProviderConfigViewSet:
"credentials",
[
{}, # empty credentials
- {"token": "sk-testT3BlbkFJkey"}, # wrong key name
+ {"token": "sk-fake-test-key-1234"}, # wrong key name
{"api_key": "ks-invalid-format"}, # wrong format
],
)
@@ -11099,7 +12624,7 @@ class TestLighthouseProviderConfigViewSet:
def test_openai_valid_credentials_success(self, authenticated_client):
"""OpenAI provider with valid sk-xxx format should succeed"""
- valid_key = "sk-abc123T3BlbkFJxyz456"
+ valid_key = "sk-fake-abc-test-key-xyz"
payload = {
"data": {
"type": "lighthouse-providers",
@@ -11124,7 +12649,7 @@ class TestLighthouseProviderConfigViewSet:
def test_openai_provider_duplicate_per_tenant(self, authenticated_client):
"""If an OpenAI provider exists for tenant, creating again should error"""
- valid_key = "sk-dup123T3BlbkFJdup456"
+ valid_key = "sk-fake-dup-test-key-456"
payload = {
"data": {
"type": "lighthouse-providers",
@@ -11153,7 +12678,7 @@ class TestLighthouseProviderConfigViewSet:
def test_openai_patch_base_url_and_is_active(self, authenticated_client):
"""After creating, should be able to patch base_url and is_active"""
- valid_key = "sk-patch123T3BlbkFJpatch456"
+ valid_key = "sk-fake-patch-test-key-456"
create_payload = {
"data": {
"type": "lighthouse-providers",
@@ -11193,7 +12718,7 @@ class TestLighthouseProviderConfigViewSet:
def test_openai_patch_invalid_credentials(self, authenticated_client):
"""PATCH with invalid credentials.api_key should error (400)"""
- valid_key = "sk-ok123T3BlbkFJok456"
+ valid_key = "sk-fake-ok-test-key-456"
create_payload = {
"data": {
"type": "lighthouse-providers",
@@ -11229,7 +12754,7 @@ class TestLighthouseProviderConfigViewSet:
assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST
def test_openai_get_masking_and_fields_filter(self, authenticated_client):
- valid_key = "sk-get123T3BlbkFJget456"
+ valid_key = "sk-fake-get-test-key-456"
create_payload = {
"data": {
"type": "lighthouse-providers",
@@ -11275,7 +12800,7 @@ class TestLighthouseProviderConfigViewSet:
provider = LighthouseProviderConfiguration.objects.create(
tenant_id=tenant.id,
provider_type="openai",
- credentials=b'{"api_key":"sk-test123T3BlbkFJ"}',
+ credentials=b'{"api_key":"sk-fake-test-key-123"}',
is_active=True,
)
diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py
index bc203c1584..ca3e4db86b 100644
--- a/api/src/backend/api/utils.py
+++ b/api/src/backend/api/utils.py
@@ -1,4 +1,7 @@
+from __future__ import annotations
+
from datetime import datetime, timezone
+from typing import TYPE_CHECKING
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from django.contrib.postgres.aggregates import ArrayAgg
@@ -11,19 +14,25 @@ from api.exceptions import InvitationTokenExpiredException
from api.models import Integration, Invitation, Processor, Provider, Resource
from api.v1.serializers import FindingMetadataSerializer
from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError
-from prowler.providers.alibabacloud.alibabacloud_provider import AlibabacloudProvider
-from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.s3.s3 import S3
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
-from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.common.models import Connection
-from prowler.providers.gcp.gcp_provider import GcpProvider
-from prowler.providers.github.github_provider import GithubProvider
-from prowler.providers.iac.iac_provider import IacProvider
-from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
-from prowler.providers.m365.m365_provider import M365Provider
-from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider
-from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
+
+if TYPE_CHECKING:
+ from prowler.providers.alibabacloud.alibabacloud_provider import (
+ AlibabacloudProvider,
+ )
+ from prowler.providers.aws.aws_provider import AwsProvider
+ from prowler.providers.azure.azure_provider import AzureProvider
+ from prowler.providers.gcp.gcp_provider import GcpProvider
+ from prowler.providers.github.github_provider import GithubProvider
+ from prowler.providers.iac.iac_provider import IacProvider
+ from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
+ from prowler.providers.m365.m365_provider import M365Provider
+ from prowler.providers.mongodbatlas.mongodbatlas_provider import (
+ MongodbatlasProvider,
+ )
+ from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
class CustomOAuth2Client(OAuth2Client):
@@ -89,24 +98,52 @@ def return_prowler_provider(
"""
match provider.provider:
case Provider.ProviderChoices.AWS.value:
+ from prowler.providers.aws.aws_provider import AwsProvider
+
prowler_provider = AwsProvider
case Provider.ProviderChoices.GCP.value:
+ from prowler.providers.gcp.gcp_provider import GcpProvider
+
prowler_provider = GcpProvider
case Provider.ProviderChoices.AZURE.value:
+ from prowler.providers.azure.azure_provider import AzureProvider
+
prowler_provider = AzureProvider
case Provider.ProviderChoices.KUBERNETES.value:
+ from prowler.providers.kubernetes.kubernetes_provider import (
+ KubernetesProvider,
+ )
+
prowler_provider = KubernetesProvider
case Provider.ProviderChoices.M365.value:
+ from prowler.providers.m365.m365_provider import M365Provider
+
prowler_provider = M365Provider
case Provider.ProviderChoices.GITHUB.value:
+ from prowler.providers.github.github_provider import GithubProvider
+
prowler_provider = GithubProvider
case Provider.ProviderChoices.MONGODBATLAS.value:
+ from prowler.providers.mongodbatlas.mongodbatlas_provider import (
+ MongodbatlasProvider,
+ )
+
prowler_provider = MongodbatlasProvider
case Provider.ProviderChoices.IAC.value:
+ from prowler.providers.iac.iac_provider import IacProvider
+
prowler_provider = IacProvider
case Provider.ProviderChoices.ORACLECLOUD.value:
+ from prowler.providers.oraclecloud.oraclecloud_provider import (
+ OraclecloudProvider,
+ )
+
prowler_provider = OraclecloudProvider
case Provider.ProviderChoices.ALIBABACLOUD.value:
+ from prowler.providers.alibabacloud.alibabacloud_provider import (
+ AlibabacloudProvider,
+ )
+
prowler_provider = AlibabacloudProvider
case _:
raise ValueError(f"Provider type {provider.provider} not supported")
@@ -393,11 +430,21 @@ def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset):
categories_set.update(categories_list)
categories = sorted(categories_set)
+ # Aggregate groups from findings
+ groups = list(
+ filtered_queryset.exclude(resource_groups__isnull=True)
+ .exclude(resource_groups__exact="")
+ .values_list("resource_groups", flat=True)
+ .distinct()
+ .order_by("resource_groups")
+ )
+
result = {
"services": services,
"regions": regions,
"resource_types": resource_types,
"categories": categories,
+ "groups": groups,
}
serializer = FindingMetadataSerializer(data=result)
diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py
index 00c8c37dfb..fac8d1cd57 100644
--- a/api/src/backend/api/v1/serializers.py
+++ b/api/src/backend/api/v1/serializers.py
@@ -21,6 +21,7 @@ from rest_framework_simplejwt.tokens import RefreshToken
from api.db_router import MainRouter
from api.exceptions import ConflictException
from api.models import (
+ AttackPathsScan,
Finding,
Integration,
IntegrationProviderRelationship,
@@ -1132,6 +1133,109 @@ class ScanComplianceReportSerializer(BaseSerializerV1):
fields = ["id", "name"]
+class AttackPathsScanSerializer(RLSSerializer):
+ state = StateEnumSerializerField(read_only=True)
+ provider_alias = serializers.SerializerMethodField(read_only=True)
+ provider_type = serializers.SerializerMethodField(read_only=True)
+ provider_uid = serializers.SerializerMethodField(read_only=True)
+
+ class Meta:
+ model = AttackPathsScan
+ fields = [
+ "id",
+ "state",
+ "progress",
+ "provider",
+ "provider_alias",
+ "provider_type",
+ "provider_uid",
+ "scan",
+ "task",
+ "inserted_at",
+ "started_at",
+ "completed_at",
+ "duration",
+ ]
+
+ included_serializers = {
+ "provider": "api.v1.serializers.ProviderIncludeSerializer",
+ "scan": "api.v1.serializers.ScanIncludeSerializer",
+ "task": "api.v1.serializers.TaskSerializer",
+ }
+
+ def get_provider_alias(self, obj):
+ provider = getattr(obj, "provider", None)
+ return provider.alias if provider else None
+
+ def get_provider_type(self, obj):
+ provider = getattr(obj, "provider", None)
+ return provider.provider if provider else None
+
+ def get_provider_uid(self, obj):
+ provider = getattr(obj, "provider", None)
+ return provider.uid if provider else None
+
+
+class AttackPathsQueryParameterSerializer(BaseSerializerV1):
+ name = serializers.CharField()
+ label = serializers.CharField()
+ data_type = serializers.CharField(default="string")
+ description = serializers.CharField(allow_null=True, required=False)
+ placeholder = serializers.CharField(allow_null=True, required=False)
+
+ class JSONAPIMeta:
+ resource_name = "attack-paths-query-parameters"
+
+
+class AttackPathsQuerySerializer(BaseSerializerV1):
+ id = serializers.CharField()
+ name = serializers.CharField()
+ description = serializers.CharField()
+ provider = serializers.CharField()
+ parameters = AttackPathsQueryParameterSerializer(many=True)
+
+ class JSONAPIMeta:
+ resource_name = "attack-paths-queries"
+
+
+class AttackPathsQueryRunRequestSerializer(BaseSerializerV1):
+ id = serializers.CharField()
+ parameters = serializers.DictField(
+ child=serializers.JSONField(), allow_empty=True, required=False
+ )
+
+ class JSONAPIMeta:
+ resource_name = "attack-paths-query-run-requests"
+
+
+class AttackPathsNodeSerializer(BaseSerializerV1):
+ id = serializers.CharField()
+ labels = serializers.ListField(child=serializers.CharField())
+ properties = serializers.DictField(child=serializers.JSONField())
+
+ class JSONAPIMeta:
+ resource_name = "attack-paths-query-result-nodes"
+
+
+class AttackPathsRelationshipSerializer(BaseSerializerV1):
+ id = serializers.CharField()
+ label = serializers.CharField()
+ source = serializers.CharField()
+ target = serializers.CharField()
+ properties = serializers.DictField(child=serializers.JSONField())
+
+ class JSONAPIMeta:
+ resource_name = "attack-paths-query-result-relationships"
+
+
+class AttackPathsQueryResultSerializer(BaseSerializerV1):
+ nodes = AttackPathsNodeSerializer(many=True)
+ relationships = AttackPathsRelationshipSerializer(many=True)
+
+ class JSONAPIMeta:
+ resource_name = "attack-paths-query-results"
+
+
class ResourceTagSerializer(RLSSerializer):
"""
Serializer for the ResourceTag model
@@ -1175,6 +1279,7 @@ class ResourceSerializer(RLSSerializer):
"metadata",
"details",
"partition",
+ "groups",
]
extra_kwargs = {
"id": {"read_only": True},
@@ -1183,6 +1288,7 @@ class ResourceSerializer(RLSSerializer):
"metadata": {"read_only": True},
"details": {"read_only": True},
"partition": {"read_only": True},
+ "groups": {"read_only": True},
}
included_serializers = {
@@ -1276,6 +1382,7 @@ class ResourceMetadataSerializer(BaseSerializerV1):
services = serializers.ListField(child=serializers.CharField(), allow_empty=True)
regions = serializers.ListField(child=serializers.CharField(), allow_empty=True)
types = serializers.ListField(child=serializers.CharField(), allow_empty=True)
+ groups = serializers.ListField(child=serializers.CharField(), allow_empty=True)
# Temporarily disabled until we implement tag filtering in the UI
# tags = serializers.JSONField(help_text="Tags are described as key-value pairs.")
@@ -1302,6 +1409,7 @@ class FindingSerializer(RLSSerializer):
"check_id",
"check_metadata",
"categories",
+ "resource_groups",
"raw_result",
"inserted_at",
"updated_at",
@@ -1358,6 +1466,9 @@ class FindingMetadataSerializer(BaseSerializerV1):
child=serializers.CharField(), allow_empty=True
)
categories = serializers.ListField(child=serializers.CharField(), allow_empty=True)
+ groups = serializers.ListField(
+ child=serializers.CharField(), allow_empty=True, required=False, default=list
+ )
# Temporarily disabled until we implement tag filtering in the UI
# tags = serializers.JSONField(help_text="Tags are described as key-value pairs.")
@@ -2303,6 +2414,22 @@ class CategoryOverviewSerializer(BaseSerializerV1):
resource_name = "category-overviews"
+class ResourceGroupOverviewSerializer(BaseSerializerV1):
+ """Serializer for resource group overview aggregations."""
+
+ id = serializers.CharField(source="resource_group")
+ total_findings = serializers.IntegerField()
+ failed_findings = serializers.IntegerField()
+ new_failed_findings = serializers.IntegerField()
+ resources_count = serializers.IntegerField()
+ severity = serializers.JSONField(
+ help_text="Severity breakdown: {informational, low, medium, high, critical}"
+ )
+
+ class JSONAPIMeta:
+ resource_name = "resource-group-overviews"
+
+
class ComplianceWatchlistOverviewSerializer(BaseSerializerV1):
"""Serializer for compliance watchlist overview with FAIL-dominant aggregation."""
@@ -3848,3 +3975,31 @@ class ThreatScoreSnapshotSerializer(RLSSerializer):
if getattr(obj, "_aggregated", False):
return "n/a"
return str(obj.id)
+
+
+# Resource Events Serializers
+
+
+class ResourceEventSerializer(BaseSerializerV1):
+ """Serializer for resource events (CloudTrail modification history).
+
+ NOTE: drf-spectacular auto-generates fields[resource-events] sparse fieldsets
+ parameter in the OpenAPI schema. This endpoint does not support sparse fieldsets.
+ """
+
+ id = serializers.CharField(source="event_id")
+ event_time = serializers.DateTimeField()
+ event_name = serializers.CharField()
+ event_source = serializers.CharField()
+ actor = serializers.CharField()
+ actor_uid = serializers.CharField(allow_null=True, required=False)
+ actor_type = serializers.CharField(allow_null=True, required=False)
+ source_ip_address = serializers.CharField(allow_null=True, required=False)
+ user_agent = serializers.CharField(allow_null=True, required=False)
+ request_data = serializers.JSONField(allow_null=True, required=False)
+ response_data = serializers.JSONField(allow_null=True, required=False)
+ error_code = serializers.CharField(allow_null=True, required=False)
+ error_message = serializers.CharField(allow_null=True, required=False)
+
+ class Meta:
+ resource_name = "resource-events"
diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py
index d879d1476b..840f027b42 100644
--- a/api/src/backend/api/v1/urls.py
+++ b/api/src/backend/api/v1/urls.py
@@ -4,6 +4,7 @@ from drf_spectacular.views import SpectacularRedocView
from rest_framework_nested import routers
from api.v1.views import (
+ AttackPathsScanViewSet,
ComplianceOverviewViewSet,
CustomSAMLLoginView,
CustomTokenObtainView,
@@ -53,6 +54,9 @@ router.register(r"tenants", TenantViewSet, basename="tenant")
router.register(r"providers", ProviderViewSet, basename="provider")
router.register(r"provider-groups", ProviderGroupViewSet, basename="providergroup")
router.register(r"scans", ScanViewSet, basename="scan")
+router.register(
+ r"attack-paths-scans", AttackPathsScanViewSet, basename="attack-paths-scans"
+)
router.register(r"tasks", TaskViewSet, basename="task")
router.register(r"resources", ResourceViewSet, basename="resource")
router.register(r"findings", FindingViewSet, basename="finding")
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index ffdfc1005e..abb81d5a3d 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -41,8 +41,9 @@ from django.db.models import (
Sum,
Value,
When,
+ Window,
)
-from django.db.models.functions import Coalesce
+from django.db.models.functions import Coalesce, RowNumber
from django.http import HttpResponse, QueryDict
from django.shortcuts import redirect
from django.urls import reverse
@@ -73,6 +74,7 @@ from rest_framework.permissions import SAFE_METHODS
from rest_framework_json_api.views import RelationshipView, Response
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from tasks.beat import schedule_provider_scan
+from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
from tasks.jobs.export import get_s3_client
from tasks.tasks import (
backfill_compliance_summaries_task,
@@ -89,6 +91,9 @@ from tasks.tasks import (
refresh_lighthouse_provider_models_task,
)
+from api.attack_paths import database as graph_database
+from api.attack_paths import get_queries_for_provider, get_query_by_id
+from api.attack_paths import views_helpers as attack_paths_views_helpers
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
from api.compliance import (
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
@@ -96,8 +101,15 @@ from api.compliance import (
)
from api.db_router import MainRouter
from api.db_utils import rls_transaction
-from api.exceptions import TaskFailedException
+from api.exceptions import (
+ TaskFailedException,
+ UpstreamAccessDeniedError,
+ UpstreamAuthenticationError,
+ UpstreamInternalError,
+ UpstreamServiceUnavailableError,
+)
from api.filters import (
+ AttackPathsScanFilter,
AttackSurfaceOverviewFilter,
CategoryOverviewFilter,
ComplianceOverviewFilter,
@@ -119,6 +131,7 @@ from api.filters import (
ProviderGroupFilter,
ProviderSecretFilter,
ResourceFilter,
+ ResourceGroupOverviewFilter,
RoleFilter,
ScanFilter,
ScanSummaryFilter,
@@ -130,6 +143,7 @@ from api.filters import (
UserFilter,
)
from api.models import (
+ AttackPathsScan,
AttackSurfaceOverview,
ComplianceOverviewSummary,
ComplianceRequirementOverview,
@@ -160,6 +174,7 @@ from api.models import (
SAMLToken,
Scan,
ScanCategorySummary,
+ ScanGroupSummary,
ScanSummary,
SeverityChoices,
StateChoices,
@@ -176,11 +191,16 @@ from api.rls import Tenant
from api.utils import (
CustomOAuth2Client,
get_findings_metadata_no_aggregations,
+ initialize_prowler_provider,
validate_invitation,
)
from api.uuid_utils import datetime_to_uuid7, uuid7_start
from api.v1.mixins import DisablePaginationMixin, PaginateByPkMixin, TaskManagementMixin
from api.v1.serializers import (
+ AttackPathsQueryResultSerializer,
+ AttackPathsQueryRunRequestSerializer,
+ AttackPathsQuerySerializer,
+ AttackPathsScanSerializer,
AttackSurfaceOverviewSerializer,
CategoryOverviewSerializer,
ComplianceOverviewAttributesSerializer,
@@ -233,6 +253,8 @@ from api.v1.serializers import (
ProviderSecretUpdateSerializer,
ProviderSerializer,
ProviderUpdateSerializer,
+ ResourceEventSerializer,
+ ResourceGroupOverviewSerializer,
ResourceMetadataSerializer,
ResourceSerializer,
RoleCreateSerializer,
@@ -262,6 +284,13 @@ from api.v1.serializers import (
UserSerializer,
UserUpdateSerializer,
)
+from prowler.providers.aws.exceptions.exceptions import (
+ AWSAssumeRoleError,
+ AWSCredentialsError,
+)
+from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import (
+ CloudTrailTimeline,
+)
logger = logging.getLogger(BackendLogger.API)
@@ -363,7 +392,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
- spectacular_settings.VERSION = "1.18.0"
+ spectacular_settings.VERSION = "1.19.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -405,6 +434,10 @@ class SchemaView(SpectacularAPIView):
"name": "Scan",
"description": "Endpoints for triggering manual scans and viewing scan results.",
},
+ {
+ "name": "Attack Paths",
+ "description": "Endpoints for Attack Paths scan status and executing Attack Paths queries.",
+ },
{
"name": "Schedule",
"description": "Endpoints for managing scan schedules, allowing configuration of automated "
@@ -2155,6 +2188,12 @@ class ScanViewSet(BaseRLSViewSet):
},
)
+ attack_paths_db_utils.create_attack_paths_scan(
+ tenant_id=self.request.tenant_id,
+ scan_id=str(scan.id),
+ provider_id=str(scan.provider_id),
+ )
+
prowler_task = Task.objects.get(id=task.id)
scan.task_id = task.id
scan.save(update_fields=["task_id"])
@@ -2235,6 +2274,188 @@ class TaskViewSet(BaseRLSViewSet):
)
+@extend_schema_view(
+ list=extend_schema(
+ tags=["Attack Paths"],
+ summary="List Attack Paths scans",
+ description="Retrieve Attack Paths scans for the tenant with support for filtering, ordering, and pagination.",
+ ),
+ retrieve=extend_schema(
+ tags=["Attack Paths"],
+ summary="Retrieve Attack Paths scan details",
+ description="Fetch full details for a specific Attack Paths scan.",
+ ),
+ attack_paths_queries=extend_schema(
+ tags=["Attack Paths"],
+ summary="List attack paths queries",
+ description="Retrieve the catalog of Attack Paths queries available for this Attack Paths scan.",
+ responses={
+ 200: OpenApiResponse(AttackPathsQuerySerializer(many=True)),
+ 404: OpenApiResponse(
+ description="No queries found for the selected provider"
+ ),
+ },
+ ),
+ run_attack_paths_query=extend_schema(
+ tags=["Attack Paths"],
+ summary="Execute an Attack Paths query",
+ description="Execute the selected Attack Paths query against the Attack Paths graph and return the resulting subgraph.",
+ request=AttackPathsQueryRunRequestSerializer,
+ responses={
+ 200: OpenApiResponse(AttackPathsQueryResultSerializer),
+ 400: OpenApiResponse(
+ description="Bad request (e.g., Unknown Attack Paths query for the selected provider)"
+ ),
+ 404: OpenApiResponse(
+ description="No attack paths found for the given query and parameters"
+ ),
+ 500: OpenApiResponse(
+ description="Attack Paths query execution failed due to a database error"
+ ),
+ },
+ ),
+)
+class AttackPathsScanViewSet(BaseRLSViewSet):
+ queryset = AttackPathsScan.objects.all()
+ serializer_class = AttackPathsScanSerializer
+ http_method_names = ["get", "post"]
+ filterset_class = AttackPathsScanFilter
+ ordering = ["-inserted_at"]
+ ordering_fields = [
+ "inserted_at",
+ "started_at",
+ ]
+ # RBAC required permissions
+ required_permissions = [Permissions.MANAGE_SCANS]
+
+ def set_required_permissions(self):
+ if self.request.method in SAFE_METHODS:
+ self.required_permissions = []
+
+ else:
+ self.required_permissions = [Permissions.MANAGE_SCANS]
+
+ def get_serializer_class(self):
+ if self.action == "run_attack_paths_query":
+ return AttackPathsQueryRunRequestSerializer
+
+ return super().get_serializer_class()
+
+ def get_queryset(self):
+ user_roles = get_role(self.request.user)
+ base_queryset = AttackPathsScan.objects.filter(tenant_id=self.request.tenant_id)
+
+ if user_roles.unlimited_visibility:
+ queryset = base_queryset
+
+ else:
+ queryset = base_queryset.filter(provider__in=get_providers(user_roles))
+
+ return queryset.select_related("provider", "scan", "task")
+
+ def list(self, request, *args, **kwargs):
+ queryset = self.filter_queryset(self.get_queryset())
+
+ latest_per_provider = queryset.annotate(
+ latest_scan_rank=Window(
+ expression=RowNumber(),
+ partition_by=[F("provider_id")],
+ order_by=[F("inserted_at").desc()],
+ )
+ ).filter(latest_scan_rank=1)
+
+ page = self.paginate_queryset(latest_per_provider)
+ if page is not None:
+ serializer = self.get_serializer(page, many=True)
+ return self.get_paginated_response(serializer.data)
+
+ serializer = self.get_serializer(latest_per_provider, many=True)
+ return Response(serializer.data)
+
+ @extend_schema(exclude=True)
+ def create(self, request, *args, **kwargs):
+ raise MethodNotAllowed(method="POST")
+
+ @extend_schema(exclude=True)
+ def destroy(self, request, *args, **kwargs):
+ raise MethodNotAllowed(method="DELETE")
+
+ @action(
+ detail=True,
+ methods=["get"],
+ url_path="queries",
+ url_name="queries",
+ )
+ def attack_paths_queries(self, request, pk=None):
+ attack_paths_scan = self.get_object()
+ queries = get_queries_for_provider(attack_paths_scan.provider.provider)
+
+ if not queries:
+ return Response(
+ {"detail": "No queries found for the selected provider"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ serializer = AttackPathsQuerySerializer(queries, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @action(
+ detail=True,
+ methods=["post"],
+ url_path="queries/run",
+ url_name="queries-run",
+ )
+ def run_attack_paths_query(self, request, pk=None):
+ attack_paths_scan = self.get_object()
+
+ if attack_paths_scan.state != StateChoices.COMPLETED:
+ raise ValidationError(
+ {
+ "detail": "The Attack Paths scan must be completed before running Attack Paths queries"
+ }
+ )
+
+ if not attack_paths_scan.graph_database:
+ logger.error(
+ f"The Attack Paths Scan {attack_paths_scan.id} does not reference a graph database"
+ )
+ return Response(
+ {"detail": "The Attack Paths scan does not reference a graph database"},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ payload = attack_paths_views_helpers.normalize_run_payload(request.data)
+ serializer = AttackPathsQueryRunRequestSerializer(data=payload)
+ serializer.is_valid(raise_exception=True)
+
+ query_definition = get_query_by_id(serializer.validated_data["id"])
+ if (
+ query_definition is None
+ or query_definition.provider != attack_paths_scan.provider.provider
+ ):
+ raise ValidationError(
+ {"id": "Unknown Attack Paths query for the selected provider"}
+ )
+
+ parameters = attack_paths_views_helpers.prepare_query_parameters(
+ query_definition,
+ serializer.validated_data.get("parameters", {}),
+ attack_paths_scan.provider.uid,
+ )
+
+ graph = attack_paths_views_helpers.execute_attack_paths_query(
+ attack_paths_scan, query_definition, parameters
+ )
+ graph_database.clear_cache(attack_paths_scan.graph_database)
+
+ status_code = status.HTTP_200_OK
+ if not graph.get("nodes"):
+ status_code = status.HTTP_404_NOT_FOUND
+
+ response_serializer = AttackPathsQueryResultSerializer(graph)
+ return Response(response_serializer.data, status=status_code)
+
+
@extend_schema_view(
list=extend_schema(
tags=["Resource"],
@@ -2293,6 +2514,20 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
http_method_names = ["get"]
filterset_class = ResourceFilter
ordering = ["-failed_findings_count", "-updated_at"]
+
+ # Events endpoint constants (currently AWS-only, limited to 90 days by CloudTrail Event History)
+ EVENTS_DEFAULT_LOOKBACK_DAYS = 90
+ EVENTS_MIN_LOOKBACK_DAYS = 1
+ EVENTS_MAX_LOOKBACK_DAYS = 90
+ # Page size controls how many events CloudTrail returns (prepares for API pagination)
+ EVENTS_DEFAULT_PAGE_SIZE = 50
+ EVENTS_MIN_PAGE_SIZE = 1
+ EVENTS_MAX_PAGE_SIZE = 50 # CloudTrail lookup_events max is 50
+ # Allowed query parameters for the events endpoint
+ EVENTS_ALLOWED_PARAMS = frozenset(
+ {"lookback_days", "page[size]", "include_read_events"}
+ )
+
ordering_fields = [
"provider_uid",
"uid",
@@ -2368,6 +2603,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
def get_serializer_class(self):
if self.action in ["metadata", "metadata_latest"]:
return ResourceMetadataSerializer
+ if self.action == "events":
+ return ResourceEventSerializer
return super().get_serializer_class()
def get_filterset_class(self):
@@ -2376,8 +2613,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
return ResourceFilter
def filter_queryset(self, queryset):
- # Do not apply filters when retrieving specific resource
- if self.action == "retrieve":
+ # Do not apply filters when retrieving specific resource or events
+ if self.action in ["retrieve", "events"]:
return queryset
return super().filter_queryset(queryset)
@@ -2527,10 +2764,20 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
.order_by("resource_type")
)
+ # Get groups from Resource model (flatten ArrayField)
+ all_groups = Resource.objects.filter(
+ tenant_id=tenant_id,
+ groups__isnull=False,
+ ).values_list("groups", flat=True)
+ groups = sorted(
+ set(g for groups_list in all_groups if groups_list for g in groups_list)
+ )
+
result = {
"services": services,
"regions": regions,
"types": resource_types,
+ "groups": groups,
}
serializer = self.get_serializer(data=result)
@@ -2587,16 +2834,243 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
.order_by("resource_type")
)
+ # Get groups from Resource model for resources in latest scans (flatten ArrayField)
+ all_groups = Resource.objects.filter(
+ tenant_id=tenant_id,
+ groups__isnull=False,
+ ).values_list("groups", flat=True)
+ groups = sorted(
+ set(g for groups_list in all_groups if groups_list for g in groups_list)
+ )
+
result = {
"services": services,
"regions": regions,
"types": resource_types,
+ "groups": groups,
}
serializer = self.get_serializer(data=result)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
+ @extend_schema(
+ tags=["Resource"],
+ summary="Get events for a resource",
+ description=(
+ "Retrieve events showing modification history for a resource. "
+ "Returns who modified the resource and when. Currently only available for AWS resources.\n\n"
+ "**Note:** Some events may not appear due to CloudTrail indexing limitations. "
+ "Not all AWS API calls record the resource identifier in a searchable format."
+ ),
+ parameters=[
+ OpenApiParameter(
+ name="lookback_days",
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.QUERY,
+ description="Number of days to look back (default: 90, min: 1, max: 90).",
+ required=False,
+ ),
+ OpenApiParameter(
+ name="page[size]",
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.QUERY,
+ description="Maximum number of events to return (default: 50, min: 1, max: 50).",
+ required=False,
+ ),
+ OpenApiParameter(
+ name="include_read_events",
+ type=OpenApiTypes.BOOL,
+ location=OpenApiParameter.QUERY,
+ description=(
+ "Include read-only events (Describe*, Get*, List*, etc.). "
+ "Default: false. Set to true to include all events."
+ ),
+ required=False,
+ ),
+ # NOTE: drf-spectacular auto-generates page[number] and fields[resource-events]
+ # parameters. This endpoint does not support pagination (results are limited by
+ # page[size] only) nor sparse fieldsets.
+ ],
+ responses={
+ 200: ResourceEventSerializer(many=True),
+ 400: OpenApiResponse(description="Invalid provider or parameters"),
+ 500: OpenApiResponse(description="Unexpected error retrieving events"),
+ 502: OpenApiResponse(
+ description="Provider credentials invalid, expired, or lack required permissions"
+ ),
+ 503: OpenApiResponse(description="Provider service unavailable"),
+ },
+ )
+ @action(
+ detail=True,
+ methods=["get"],
+ url_name="events",
+ filter_backends=[], # Disable filters - we're calling external API, not filtering queryset
+ )
+ def events(self, request, pk=None):
+ """Get events for a resource."""
+ resource = self.get_object()
+
+ # Validate query parameters - reject unknown parameters
+ for param in request.query_params.keys():
+ if param not in self.EVENTS_ALLOWED_PARAMS:
+ raise ValidationError(
+ [
+ {
+ "detail": f"invalid parameter '{param}'",
+ "status": "400",
+ "source": {"parameter": param},
+ "code": "invalid",
+ }
+ ]
+ )
+
+ # Validate provider - currently only AWS CloudTrail is supported
+ if resource.provider.provider != Provider.ProviderChoices.AWS:
+ raise ValidationError(
+ [
+ {
+ "detail": "Events are only available for AWS resources",
+ "status": "400",
+ "source": {"pointer": "/data/attributes/provider"},
+ "code": "invalid_provider",
+ }
+ ]
+ )
+
+ # Validate and parse lookback_days from query params
+ lookback_days_str = request.query_params.get("lookback_days")
+ if lookback_days_str is None:
+ lookback_days = self.EVENTS_DEFAULT_LOOKBACK_DAYS
+ else:
+ try:
+ lookback_days = int(lookback_days_str)
+ except (ValueError, TypeError):
+ raise ValidationError(
+ [
+ {
+ "detail": "lookback_days must be a valid integer",
+ "status": "400",
+ "source": {"parameter": "lookback_days"},
+ "code": "invalid",
+ }
+ ]
+ )
+
+ if not (
+ self.EVENTS_MIN_LOOKBACK_DAYS
+ <= lookback_days
+ <= self.EVENTS_MAX_LOOKBACK_DAYS
+ ):
+ raise ValidationError(
+ [
+ {
+ "detail": (
+ f"lookback_days must be between {self.EVENTS_MIN_LOOKBACK_DAYS} "
+ f"and {self.EVENTS_MAX_LOOKBACK_DAYS}"
+ ),
+ "status": "400",
+ "source": {"parameter": "lookback_days"},
+ "code": "out_of_range",
+ }
+ ]
+ )
+
+ # Validate and parse page[size] from query params (JSON:API pagination)
+ page_size_str = request.query_params.get("page[size]")
+ if page_size_str is None:
+ page_size = self.EVENTS_DEFAULT_PAGE_SIZE
+ else:
+ try:
+ page_size = int(page_size_str)
+ except (ValueError, TypeError):
+ raise ValidationError(
+ [
+ {
+ "detail": "page[size] must be a valid integer",
+ "status": "400",
+ "source": {"parameter": "page[size]"},
+ "code": "invalid",
+ }
+ ]
+ )
+
+ if not (
+ self.EVENTS_MIN_PAGE_SIZE <= page_size <= self.EVENTS_MAX_PAGE_SIZE
+ ):
+ raise ValidationError(
+ [
+ {
+ "detail": (
+ f"page[size] must be between {self.EVENTS_MIN_PAGE_SIZE} "
+ f"and {self.EVENTS_MAX_PAGE_SIZE}"
+ ),
+ "status": "400",
+ "source": {"parameter": "page[size]"},
+ "code": "out_of_range",
+ }
+ ]
+ )
+
+ # Parse include_read_events (default: false)
+ include_read_events = (
+ request.query_params.get("include_read_events", "").lower() == "true"
+ )
+
+ try:
+ # Initialize Prowler provider using existing utility
+ prowler_provider = initialize_prowler_provider(resource.provider)
+
+ # Get the boto3 session from the Prowler provider
+ session = prowler_provider._session.current_session
+
+ # Create timeline service (currently only AWS/CloudTrail is supported)
+ timeline_service = CloudTrailTimeline(
+ session=session,
+ lookback_days=lookback_days,
+ max_results=page_size,
+ write_events_only=not include_read_events,
+ )
+
+ # Get timeline events
+ events = timeline_service.get_resource_timeline(
+ region=resource.region,
+ resource_uid=resource.uid,
+ )
+
+ serializer = ResourceEventSerializer(events, many=True)
+ return Response(serializer.data)
+
+ except NoCredentialsError:
+ # 502 because this is an upstream auth failure, not API auth failure
+ raise UpstreamAuthenticationError(
+ detail="Credentials not found for this provider. Please reconnect the provider."
+ )
+ except AWSAssumeRoleError:
+ # AssumeRole failed - usually IAM permission issue (not authorized to sts:AssumeRole)
+ raise UpstreamAccessDeniedError(
+ detail="Cannot assume role for this provider. Check IAM Role permissions and trust relationship."
+ )
+ except AWSCredentialsError:
+ # Handles expired tokens, invalid keys, profile not found, etc.
+ raise UpstreamAuthenticationError()
+ except ClientError as e:
+ error_code = e.response.get("Error", {}).get("Code", "")
+ # AccessDenied is expected when credentials lack permissions - don't log as error
+ if error_code in ("AccessDenied", "AccessDeniedException"):
+ raise UpstreamAccessDeniedError()
+
+ # Unexpected ClientErrors should be logged for debugging
+ logger.error(
+ f"Provider API error retrieving events: {str(e)}",
+ exc_info=True,
+ )
+ raise UpstreamServiceUnavailableError()
+ except Exception as e:
+ sentry_sdk.capture_exception(e)
+ raise UpstreamInternalError(detail="Failed to retrieve events")
+
@extend_schema_view(
list=extend_schema(
@@ -3019,11 +3493,23 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
categories_set.update(categories_list)
categories = sorted(categories_set)
+ # Get groups from ScanGroupSummary for latest scans
+ groups = list(
+ ScanGroupSummary.objects.filter(
+ tenant_id=tenant_id,
+ scan_id__in=latest_scans_queryset.values_list("id", flat=True),
+ )
+ .values_list("resource_group", flat=True)
+ .distinct()
+ .order_by("resource_group")
+ )
+
result = {
"services": services,
"regions": regions,
"resource_types": resource_types,
"categories": categories,
+ "groups": groups,
}
serializer = self.get_serializer(data=result)
@@ -3958,7 +4444,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
# If we couldn't determine from database, try each provider type
if not provider_type:
for pt in Provider.ProviderChoices.values:
- if compliance_id in PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(pt, {}):
+ if compliance_id in get_compliance_frameworks(pt):
provider_type = pt
break
@@ -4097,6 +4583,30 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
filters=True,
responses={200: CategoryOverviewSerializer(many=True)},
),
+ resource_groups=extend_schema(
+ summary="Get resource group overview",
+ description=(
+ "Retrieve aggregated resource group metrics from latest completed scans per provider. "
+ "Returns one row per resource group with total, failed, and new failed findings counts, "
+ "plus a severity breakdown showing failed findings per severity level, "
+ "and a count of distinct resources evaluated per group."
+ ),
+ tags=["Overview"],
+ filters=True,
+ responses={200: ResourceGroupOverviewSerializer(many=True)},
+ ),
+ compliance_watchlist=extend_schema(
+ summary="Get compliance watchlist overview",
+ description=(
+ "Retrieve compliance metrics with FAIL-dominant aggregation. "
+ "Without filters: uses pre-aggregated TenantComplianceSummary. "
+ "With provider filters: queries ProviderComplianceScore with FAIL-dominant logic "
+ "where any FAIL in a requirement marks it as failed."
+ ),
+ tags=["Overview"],
+ filters=True,
+ responses={200: ComplianceWatchlistOverviewSerializer(many=True)},
+ ),
)
@method_decorator(CACHE_DECORATOR, name="list")
class OverviewViewSet(BaseRLSViewSet):
@@ -4146,6 +4656,8 @@ class OverviewViewSet(BaseRLSViewSet):
return AttackSurfaceOverviewSerializer
elif self.action == "categories":
return CategoryOverviewSerializer
+ elif self.action == "resource_groups":
+ return ResourceGroupOverviewSerializer
elif self.action == "compliance_watchlist":
return ComplianceWatchlistOverviewSerializer
return super().get_serializer_class()
@@ -4161,6 +4673,8 @@ class OverviewViewSet(BaseRLSViewSet):
return DailySeveritySummaryFilter
elif self.action == "categories":
return CategoryOverviewFilter
+ elif self.action == "resource_groups":
+ return ResourceGroupOverviewFilter
elif self.action == "attack_surface":
return AttackSurfaceOverviewFilter
elif self.action == "compliance_watchlist":
@@ -5005,6 +5519,95 @@ class OverviewViewSet(BaseRLSViewSet):
status=status.HTTP_200_OK,
)
+ @action(
+ detail=False,
+ methods=["get"],
+ url_name="resource-groups",
+ url_path="resource-groups",
+ )
+ def resource_groups(self, request):
+ tenant_id = request.tenant_id
+ provider_filters = self._extract_provider_filters_from_params()
+ latest_scan_ids = self._latest_scan_ids_for_allowed_providers(
+ tenant_id, provider_filters
+ )
+
+ base_queryset = ScanGroupSummary.objects.filter(
+ tenant_id=tenant_id, scan_id__in=latest_scan_ids
+ )
+ provider_filter_keys = {
+ "provider_id",
+ "provider_id__in",
+ "provider_type",
+ "provider_type__in",
+ }
+ filtered_queryset = self._apply_filterset(
+ base_queryset,
+ ResourceGroupOverviewFilter,
+ exclude_keys=provider_filter_keys,
+ )
+
+ aggregation = (
+ filtered_queryset.values("resource_group", "severity")
+ .annotate(
+ total=Coalesce(Sum("total_findings"), 0),
+ failed=Coalesce(Sum("failed_findings"), 0),
+ new_failed=Coalesce(Sum("new_failed_findings"), 0),
+ )
+ .order_by("resource_group", "severity")
+ )
+
+ # Get resource_group-level resources_count:
+ # 1. Max per (scan, resource_group) to deduplicate within-scan severity rows
+ # 2. Sum across scans for cross-provider aggregation
+ scan_resource_group_resources = filtered_queryset.values(
+ "scan_id", "resource_group"
+ ).annotate(resources=Coalesce(Max("resources_count"), 0))
+ resources_by_resource_group = defaultdict(int)
+ for row in scan_resource_group_resources:
+ resources_by_resource_group[row["resource_group"]] += row["resources"]
+
+ resource_group_data = defaultdict(
+ lambda: {
+ "total_findings": 0,
+ "failed_findings": 0,
+ "new_failed_findings": 0,
+ "resources_count": 0,
+ "severity": {
+ "informational": 0,
+ "low": 0,
+ "medium": 0,
+ "high": 0,
+ "critical": 0,
+ },
+ }
+ )
+
+ for row in aggregation:
+ grp = row["resource_group"]
+ sev = row["severity"]
+ resource_group_data[grp]["total_findings"] += row["total"]
+ resource_group_data[grp]["failed_findings"] += row["failed"]
+ resource_group_data[grp]["new_failed_findings"] += row["new_failed"]
+ if sev in resource_group_data[grp]["severity"]:
+ resource_group_data[grp]["severity"][sev] = row["failed"]
+
+ # Set resources_count from resource_group-level aggregation
+ for grp in resource_group_data:
+ resource_group_data[grp]["resources_count"] = (
+ resources_by_resource_group.get(grp, 0)
+ )
+
+ response_data = [
+ {"resource_group": grp, **data}
+ for grp, data in sorted(resource_group_data.items())
+ ]
+
+ return Response(
+ self.get_serializer(response_data, many=True).data,
+ status=status.HTTP_200_OK,
+ )
+
@action(
detail=False,
methods=["get"],
@@ -5760,7 +6363,7 @@ class TenantApiKeyViewSet(BaseRLSViewSet):
@extend_schema(exclude=True)
def destroy(self, request, *args, **kwargs):
- raise MethodNotAllowed(method="DESTROY")
+ raise MethodNotAllowed(method="DELETE")
@action(detail=True, methods=["delete"])
def revoke(self, request, *args, **kwargs):
diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py
index b3a0ab4b68..aaa1b1c386 100644
--- a/api/src/backend/config/celery.py
+++ b/api/src/backend/config/celery.py
@@ -1,6 +1,7 @@
import warnings
from celery import Celery, Task
+
from config.env import env
# Suppress specific warnings from django-rest-auth: https://github.com/iMerica/dj-rest-auth/issues/684
diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py
index 80b96952d7..c9e1b4750f 100644
--- a/api/src/backend/config/django/base.py
+++ b/api/src/backend/config/django/base.py
@@ -276,7 +276,7 @@ FINDINGS_MAX_DAYS_IN_RANGE = env.int("DJANGO_FINDINGS_MAX_DAYS_IN_RANGE", 7)
DJANGO_TMP_OUTPUT_DIRECTORY = env.str(
"DJANGO_TMP_OUTPUT_DIRECTORY", "/tmp/prowler_api_output"
)
-DJANGO_FINDINGS_BATCH_SIZE = env.str("DJANGO_FINDINGS_BATCH_SIZE", 1000)
+DJANGO_FINDINGS_BATCH_SIZE = env.int("DJANGO_FINDINGS_BATCH_SIZE", 1000)
DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID = env.str("DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID", "")
diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py
index 00d7f7dbcc..9c83557b77 100644
--- a/api/src/backend/config/django/devel.py
+++ b/api/src/backend/config/django/devel.py
@@ -44,6 +44,12 @@ DATABASES = {
"HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host),
"PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port),
},
+ "neo4j": {
+ "HOST": env.str("NEO4J_HOST", "neo4j"),
+ "PORT": env.str("NEO4J_PORT", "7687"),
+ "USER": env.str("NEO4J_USER", "neo4j"),
+ "PASSWORD": env.str("NEO4J_PASSWORD", "neo4j_password"),
+ },
}
DATABASES["default"] = DATABASES["prowler_user"]
diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py
index f350186ed0..b2769237fc 100644
--- a/api/src/backend/config/django/production.py
+++ b/api/src/backend/config/django/production.py
@@ -45,6 +45,12 @@ DATABASES = {
"HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host),
"PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port),
},
+ "neo4j": {
+ "HOST": env.str("NEO4J_HOST"),
+ "PORT": env.str("NEO4J_PORT"),
+ "USER": env.str("NEO4J_USER"),
+ "PASSWORD": env.str("NEO4J_PASSWORD"),
+ },
}
DATABASES["default"] = DATABASES["prowler_user"]
diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py
index 8eefab551f..a3b683ffea 100644
--- a/api/src/backend/conftest.py
+++ b/api/src/backend/conftest.py
@@ -1,8 +1,11 @@
import logging
+from types import SimpleNamespace
+
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
+
from allauth.socialaccount.models import SocialLogin
from django.conf import settings
from django.db import connection as django_connection
@@ -11,13 +14,14 @@ from django.urls import reverse
from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.test import APIClient
-from tasks.jobs.backfill import (
- backfill_resource_scan_summaries,
- backfill_scan_category_summaries,
-)
+from api.attack_paths import (
+ AttackPathsQueryDefinition,
+ AttackPathsQueryParameterDefinition,
+)
from api.db_utils import rls_transaction
from api.models import (
+ AttackPathsScan,
AttackSurfaceOverview,
ComplianceOverview,
ComplianceRequirementOverview,
@@ -41,6 +45,7 @@ from api.models import (
SAMLDomainIndex,
Scan,
ScanCategorySummary,
+ ScanGroupSummary,
ScanSummary,
StateChoices,
StatusChoices,
@@ -54,6 +59,11 @@ from api.rls import Tenant
from api.v1.serializers import TokenSerializer
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
+from tasks.jobs.backfill import (
+ backfill_resource_scan_summaries,
+ backfill_scan_category_summaries,
+ backfill_scan_resource_group_summaries,
+)
TODAY = str(datetime.today().date())
API_JSON_CONTENT_TYPE = "application/vnd.api+json"
@@ -166,22 +176,20 @@ def create_test_user_rbac_no_roles(django_db_setup, django_db_blocker, tenants_f
@pytest.fixture(scope="function")
-def create_test_user_rbac_limited(django_db_setup, django_db_blocker):
+def create_test_user_rbac_limited(django_db_setup, django_db_blocker, tenants_fixture):
with django_db_blocker.unblock():
user = User.objects.create_user(
name="testing_limited",
email="rbac_limited@rbac.com",
password=TEST_PASSWORD,
)
- tenant = Tenant.objects.create(
- name="Tenant Test",
- )
+ tenant = tenants_fixture[0]
Membership.objects.create(
user=user,
tenant=tenant,
role=Membership.RoleChoices.OWNER,
)
- Role.objects.create(
+ role = Role.objects.create(
name="limited",
tenant_id=tenant.id,
manage_users=False,
@@ -194,7 +202,7 @@ def create_test_user_rbac_limited(django_db_setup, django_db_blocker):
)
UserRoleRelationship.objects.create(
user=user,
- role=Role.objects.get(name="limited"),
+ role=role,
tenant_id=tenant.id,
)
return user
@@ -739,6 +747,7 @@ def resources_fixture(providers_fixture):
region="us-east-1",
service="ec2",
type="prowler-test",
+ groups=["compute"],
)
resource1.upsert_or_delete_tags(tags)
@@ -751,6 +760,7 @@ def resources_fixture(providers_fixture):
region="eu-west-1",
service="s3",
type="prowler-test",
+ groups=["storage"],
)
resource2.upsert_or_delete_tags(tags)
@@ -762,6 +772,7 @@ def resources_fixture(providers_fixture):
region="us-east-1",
service="ec2",
type="test",
+ groups=["compute"],
)
tags = [
@@ -1234,7 +1245,7 @@ def lighthouse_config_fixture(authenticated_client, tenants_fixture):
return LighthouseConfiguration.objects.create(
tenant_id=tenants_fixture[0].id,
name="OpenAI",
- api_key_decoded="sk-test1234567890T3BlbkFJtest1234567890",
+ api_key_decoded="sk-fake-test-key-for-unit-testing-only",
model="gpt-4o",
temperature=0,
max_tokens=4000,
@@ -1383,11 +1394,13 @@ def latest_scan_finding_with_categories(
check_id="genai_iam_check",
check_metadata={"CheckId": "genai_iam_check"},
categories=["gen-ai", "iam"],
+ resource_groups="ai_ml",
first_seen_at="2024-01-02T00:00:00Z",
)
finding.add_resources([resource])
backfill_resource_scan_summaries(tenant_id, str(scan.id))
backfill_scan_category_summaries(tenant_id, str(scan.id))
+ backfill_scan_resource_group_summaries(tenant_id, str(scan.id))
return finding
@@ -1590,6 +1603,104 @@ def mute_rules_fixture(tenants_fixture, create_test_user, findings_fixture):
return mute_rule1, mute_rule2
+@pytest.fixture
+def create_attack_paths_scan():
+ """Factory fixture to create Attack Paths scans for tests."""
+
+ def _create(
+ provider,
+ *,
+ scan=None,
+ state=StateChoices.COMPLETED,
+ progress=0,
+ graph_database="tenant-db",
+ **extra_fields,
+ ):
+ scan_instance = scan or Scan.objects.create(
+ name=extra_fields.pop("scan_name", "Attack Paths Supporting Scan"),
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=extra_fields.pop("scan_state", StateChoices.COMPLETED),
+ tenant_id=provider.tenant_id,
+ )
+
+ payload = {
+ "tenant_id": provider.tenant_id,
+ "provider": provider,
+ "scan": scan_instance,
+ "state": state,
+ "progress": progress,
+ "graph_database": graph_database,
+ }
+ payload.update(extra_fields)
+
+ return AttackPathsScan.objects.create(**payload)
+
+ return _create
+
+
+@pytest.fixture
+def attack_paths_query_definition_factory():
+ """Factory fixture for building Attack Paths query definitions."""
+
+ def _create(**overrides):
+ cast_type = overrides.pop("cast_type", str)
+ parameters = overrides.pop(
+ "parameters",
+ [
+ AttackPathsQueryParameterDefinition(
+ name="limit",
+ label="Limit",
+ cast=cast_type,
+ )
+ ],
+ )
+ definition_payload = {
+ "id": "aws-test",
+ "name": "Attack Paths Test Query",
+ "description": "Synthetic Attack Paths definition for tests.",
+ "provider": "aws",
+ "cypher": "RETURN 1",
+ "parameters": parameters,
+ }
+ definition_payload.update(overrides)
+ return AttackPathsQueryDefinition(**definition_payload)
+
+ return _create
+
+
+@pytest.fixture
+def attack_paths_graph_stub_classes():
+ """Provide lightweight graph element stubs for Attack Paths serialization tests."""
+
+ class AttackPathsNativeValue:
+ def __init__(self, value):
+ self._value = value
+
+ def to_native(self):
+ return self._value
+
+ class AttackPathsNode:
+ def __init__(self, element_id, labels, properties):
+ self.element_id = element_id
+ self.labels = labels
+ self._properties = properties
+
+ class AttackPathsRelationship:
+ def __init__(self, element_id, rel_type, start_node, end_node, properties):
+ self.element_id = element_id
+ self.type = rel_type
+ self.start_node = start_node
+ self.end_node = end_node
+ self._properties = properties
+
+ return SimpleNamespace(
+ NativeValue=AttackPathsNativeValue,
+ Node=AttackPathsNode,
+ Relationship=AttackPathsRelationship,
+ )
+
+
@pytest.fixture
def create_attack_surface_overview():
def _create(tenant, scan, attack_surface_type, total=10, failed=5, muted_failed=2):
@@ -1629,6 +1740,103 @@ def create_scan_category_summary():
return _create
+@pytest.fixture(scope="function")
+def findings_with_group(scans_fixture, resources_fixture):
+ scan = scans_fixture[0]
+ resource = resources_fixture[0]
+
+ finding = Finding.objects.create(
+ tenant_id=scan.tenant_id,
+ uid="finding_with_group_1",
+ scan=scan,
+ delta=None,
+ status=Status.FAIL,
+ status_extended="test status",
+ impact=Severity.critical,
+ impact_extended="test impact",
+ severity=Severity.critical,
+ raw_result={"status": Status.FAIL},
+ check_id="storage_check",
+ check_metadata={"CheckId": "storage_check"},
+ resource_groups="storage",
+ first_seen_at="2024-01-02T00:00:00Z",
+ )
+ finding.add_resources([resource])
+ backfill_resource_scan_summaries(str(scan.tenant_id), str(scan.id))
+ return finding
+
+
+@pytest.fixture(scope="function")
+def findings_with_multiple_groups(scans_fixture, resources_fixture):
+ scan = scans_fixture[0]
+ resource1, resource2 = resources_fixture[:2]
+
+ finding1 = Finding.objects.create(
+ tenant_id=scan.tenant_id,
+ uid="finding_multi_grp_1",
+ scan=scan,
+ delta=None,
+ status=Status.FAIL,
+ status_extended="test status",
+ impact=Severity.critical,
+ impact_extended="test impact",
+ severity=Severity.critical,
+ raw_result={"status": Status.FAIL},
+ check_id="storage_check",
+ check_metadata={"CheckId": "storage_check"},
+ resource_groups="storage",
+ first_seen_at="2024-01-02T00:00:00Z",
+ )
+ finding1.add_resources([resource1])
+
+ finding2 = Finding.objects.create(
+ tenant_id=scan.tenant_id,
+ uid="finding_multi_grp_2",
+ scan=scan,
+ delta=None,
+ status=Status.FAIL,
+ status_extended="test status 2",
+ impact=Severity.high,
+ impact_extended="test impact 2",
+ severity=Severity.high,
+ raw_result={"status": Status.FAIL},
+ check_id="security_check",
+ check_metadata={"CheckId": "security_check"},
+ resource_groups="security",
+ first_seen_at="2024-01-02T00:00:00Z",
+ )
+ finding2.add_resources([resource2])
+
+ backfill_resource_scan_summaries(str(scan.tenant_id), str(scan.id))
+ return finding1, finding2
+
+
+@pytest.fixture
+def create_scan_resource_group_summary():
+ def _create(
+ tenant,
+ scan,
+ resource_group,
+ severity,
+ total_findings=10,
+ failed_findings=5,
+ new_failed_findings=2,
+ resources_count=3,
+ ):
+ return ScanGroupSummary.objects.create(
+ tenant=tenant,
+ scan=scan,
+ resource_group=resource_group,
+ severity=severity,
+ total_findings=total_findings,
+ failed_findings=failed_findings,
+ new_failed_findings=new_failed_findings,
+ resources_count=resources_count,
+ )
+
+ return _create
+
+
def get_authorization_header(access_token: str) -> dict:
return {"Authorization": f"Bearer {access_token}"}
diff --git a/api/src/backend/tasks/beat.py b/api/src/backend/tasks/beat.py
index 262d47496a..e9eb9c9309 100644
--- a/api/src/backend/tasks/beat.py
+++ b/api/src/backend/tasks/beat.py
@@ -7,6 +7,7 @@ from tasks.tasks import perform_scheduled_scan_task
from api.db_utils import rls_transaction
from api.exceptions import ConflictException
from api.models import Provider, Scan, StateChoices
+from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
def schedule_provider_scan(provider_instance: Provider):
@@ -39,6 +40,12 @@ def schedule_provider_scan(provider_instance: Provider):
scheduled_at=datetime.now(timezone.utc),
)
+ attack_paths_db_utils.create_attack_paths_scan(
+ tenant_id=tenant_id,
+ scan_id=str(scheduled_scan.id),
+ provider_id=provider_id,
+ )
+
# Schedule the task
periodic_task_instance = PeriodicTask.objects.create(
interval=schedule,
diff --git a/api/src/backend/tasks/jobs/attack_paths/__init__.py b/api/src/backend/tasks/jobs/attack_paths/__init__.py
new file mode 100644
index 0000000000..8fb57bc907
--- /dev/null
+++ b/api/src/backend/tasks/jobs/attack_paths/__init__.py
@@ -0,0 +1,7 @@
+from tasks.jobs.attack_paths.db_utils import can_provider_run_attack_paths_scan
+from tasks.jobs.attack_paths.scan import run as attack_paths_scan
+
+__all__ = [
+ "attack_paths_scan",
+ "can_provider_run_attack_paths_scan",
+]
diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py
new file mode 100644
index 0000000000..1bfe4eefac
--- /dev/null
+++ b/api/src/backend/tasks/jobs/attack_paths/aws.py
@@ -0,0 +1,253 @@
+# Portions of this file are based on code from the Cartography project
+# (https://github.com/cartography-cncf/cartography), which is licensed under the Apache 2.0 License.
+
+from typing import Any
+
+import aioboto3
+import boto3
+import neo4j
+
+from cartography.config import Config as CartographyConfig
+from cartography.intel import aws as cartography_aws
+from celery.utils.log import get_task_logger
+
+from api.models import (
+ AttackPathsScan as ProwlerAPIAttackPathsScan,
+ Provider as ProwlerAPIProvider,
+)
+from prowler.providers.common.provider import Provider as ProwlerSDKProvider
+from tasks.jobs.attack_paths import db_utils, utils
+
+logger = get_task_logger(__name__)
+
+
+def start_aws_ingestion(
+ neo4j_session: neo4j.Session,
+ cartography_config: CartographyConfig,
+ prowler_api_provider: ProwlerAPIProvider,
+ prowler_sdk_provider: ProwlerSDKProvider,
+ attack_paths_scan: ProwlerAPIAttackPathsScan,
+) -> dict[str, dict[str, str]]:
+ """
+ Code based on Cartography version 0.122.0, specifically on `cartography.intel.aws.__init__.py`.
+
+ For the scan progress updates:
+ - The caller of this function (`tasks.jobs.attack_paths.scan.run`) has set it to 2.
+ - When the control returns to the caller, it will be set to 95.
+ """
+
+ # Initialize variables common to all jobs
+ common_job_parameters = {
+ "UPDATE_TAG": cartography_config.update_tag,
+ "permission_relationships_file": cartography_config.permission_relationships_file,
+ "aws_guardduty_severity_threshold": cartography_config.aws_guardduty_severity_threshold,
+ "aws_cloudtrail_management_events_lookback_hours": cartography_config.aws_cloudtrail_management_events_lookback_hours,
+ "experimental_aws_inspector_batch": cartography_config.experimental_aws_inspector_batch,
+ }
+
+ boto3_session = get_boto3_session(prowler_api_provider, prowler_sdk_provider)
+ regions: list[str] = list(prowler_sdk_provider._enabled_regions)
+ requested_syncs = list(cartography_aws.RESOURCE_FUNCTIONS.keys())
+
+ sync_args = cartography_aws._build_aws_sync_kwargs(
+ neo4j_session,
+ boto3_session,
+ regions,
+ prowler_api_provider.uid,
+ cartography_config.update_tag,
+ common_job_parameters,
+ )
+
+ # Starting with sync functions
+ logger.info(f"Syncing organizations for AWS account {prowler_api_provider.uid}")
+ cartography_aws.organizations.sync(
+ neo4j_session,
+ {prowler_api_provider.alias: prowler_api_provider.uid},
+ cartography_config.update_tag,
+ common_job_parameters,
+ )
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 3)
+
+ # Adding an extra field
+ common_job_parameters["AWS_ID"] = prowler_api_provider.uid
+
+ cartography_aws._autodiscover_accounts(
+ neo4j_session,
+ boto3_session,
+ prowler_api_provider.uid,
+ cartography_config.update_tag,
+ common_job_parameters,
+ )
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 4)
+
+ failed_syncs = sync_aws_account(
+ prowler_api_provider, requested_syncs, sync_args, attack_paths_scan
+ )
+
+ if "permission_relationships" in requested_syncs:
+ logger.info(
+ f"Syncing function permission_relationships for AWS account {prowler_api_provider.uid}"
+ )
+ cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"](**sync_args)
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 88)
+
+ if "resourcegroupstaggingapi" in requested_syncs:
+ logger.info(
+ f"Syncing function resourcegroupstaggingapi for AWS account {prowler_api_provider.uid}"
+ )
+ cartography_aws.RESOURCE_FUNCTIONS["resourcegroupstaggingapi"](**sync_args)
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 89)
+
+ logger.info(
+ f"Syncing ec2_iaminstanceprofile scoped analysis for AWS account {prowler_api_provider.uid}"
+ )
+ cartography_aws.run_scoped_analysis_job(
+ "aws_ec2_iaminstanceprofile.json",
+ neo4j_session,
+ common_job_parameters,
+ )
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 90)
+
+ logger.info(
+ f"Syncing lambda_ecr analysis for AWS account {prowler_api_provider.uid}"
+ )
+ cartography_aws.run_analysis_job(
+ "aws_lambda_ecr.json",
+ neo4j_session,
+ common_job_parameters,
+ )
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 91)
+
+ logger.info(f"Syncing metadata for AWS account {prowler_api_provider.uid}")
+ cartography_aws.merge_module_sync_metadata(
+ neo4j_session,
+ group_type="AWSAccount",
+ group_id=prowler_api_provider.uid,
+ synced_type="AWSAccount",
+ update_tag=cartography_config.update_tag,
+ stat_handler=cartography_aws.stat_handler,
+ )
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 92)
+
+ # Removing the added extra field
+ del common_job_parameters["AWS_ID"]
+
+ logger.info(f"Syncing cleanup_job for AWS account {prowler_api_provider.uid}")
+ cartography_aws.run_cleanup_job(
+ "aws_post_ingestion_principals_cleanup.json",
+ neo4j_session,
+ common_job_parameters,
+ )
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 93)
+
+ logger.info(f"Syncing analysis for AWS account {prowler_api_provider.uid}")
+ cartography_aws._perform_aws_analysis(
+ requested_syncs, neo4j_session, common_job_parameters
+ )
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 94)
+
+ return failed_syncs
+
+
+def get_boto3_session(
+ prowler_api_provider: ProwlerAPIProvider, prowler_sdk_provider: ProwlerSDKProvider
+) -> boto3.Session:
+ boto3_session = prowler_sdk_provider.session.current_session
+
+ aws_accounts_from_session = cartography_aws.organizations.get_aws_account_default(
+ boto3_session
+ )
+ if not aws_accounts_from_session:
+ raise Exception(
+ "No valid AWS credentials could be found. No AWS accounts can be synced."
+ )
+
+ aws_account_id_from_session = list(aws_accounts_from_session.values())[0]
+ if prowler_api_provider.uid != aws_account_id_from_session:
+ raise Exception(
+ f"Provider {prowler_api_provider.uid} doesn't match AWS account {aws_account_id_from_session}."
+ )
+
+ if boto3_session.region_name is None:
+ global_region = prowler_sdk_provider.get_global_region()
+ boto3_session._session.set_config_variable("region", global_region)
+
+ return boto3_session
+
+
+def get_aioboto3_session(boto3_session: boto3.Session) -> aioboto3.Session:
+ return aioboto3.Session(botocore_session=boto3_session._session)
+
+
+def sync_aws_account(
+ prowler_api_provider: ProwlerAPIProvider,
+ requested_syncs: list[str],
+ sync_args: dict[str, Any],
+ attack_paths_scan: ProwlerAPIAttackPathsScan,
+) -> dict[str, str]:
+ current_progress = 4 # `cartography_aws._autodiscover_accounts`
+ max_progress = (
+ 87 # `cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"]` - 1
+ )
+ n_steps = (
+ len(requested_syncs) - 2
+ ) # Excluding `permission_relationships` and `resourcegroupstaggingapi`
+ progress_step = (max_progress - current_progress) / n_steps
+
+ failed_syncs = {}
+
+ for func_name in requested_syncs:
+ if func_name in cartography_aws.RESOURCE_FUNCTIONS:
+ logger.info(
+ f"Syncing function {func_name} for AWS account {prowler_api_provider.uid}"
+ )
+
+ # Updating progress, not really the right place but good enough
+ current_progress += progress_step
+ db_utils.update_attack_paths_scan_progress(
+ attack_paths_scan, int(current_progress)
+ )
+
+ try:
+ # `ecr:image_layers` uses `aioboto3_session` instead of `boto3_session`
+ if func_name == "ecr:image_layers":
+ cartography_aws.RESOURCE_FUNCTIONS[func_name](
+ neo4j_session=sync_args.get("neo4j_session"),
+ aioboto3_session=get_aioboto3_session(
+ sync_args.get("boto3_session")
+ ),
+ regions=sync_args.get("regions"),
+ current_aws_account_id=sync_args.get("current_aws_account_id"),
+ update_tag=sync_args.get("update_tag"),
+ common_job_parameters=sync_args.get("common_job_parameters"),
+ )
+
+ # Skip permission relationships and tags for now because they rely on data already being in the graph
+ elif func_name in [
+ "permission_relationships",
+ "resourcegroupstaggingapi",
+ ]:
+ continue
+
+ else:
+ cartography_aws.RESOURCE_FUNCTIONS[func_name](**sync_args)
+
+ except Exception as e:
+ exception_message = utils.stringify_exception(
+ e, f"Exception for AWS sync function: {func_name}"
+ )
+ failed_syncs[func_name] = exception_message
+
+ logger.warning(
+ f"Caught exception syncing function {func_name} from AWS account {prowler_api_provider.uid}. We "
+ "are continuing on to the next AWS sync function.",
+ )
+
+ continue
+
+ else:
+ raise ValueError(
+ f'AWS sync function "{func_name}" was specified but does not exist. Did you misspell it?'
+ )
+
+ return failed_syncs
diff --git a/api/src/backend/tasks/jobs/attack_paths/db_utils.py b/api/src/backend/tasks/jobs/attack_paths/db_utils.py
new file mode 100644
index 0000000000..92f79f6f36
--- /dev/null
+++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py
@@ -0,0 +1,168 @@
+from datetime import datetime, timezone
+from typing import Any
+
+from django.db.models import Q
+from cartography.config import Config as CartographyConfig
+
+from api.db_utils import rls_transaction
+from api.models import (
+ AttackPathsScan as ProwlerAPIAttackPathsScan,
+ Provider as ProwlerAPIProvider,
+ StateChoices,
+)
+from tasks.jobs.attack_paths.providers import is_provider_available
+
+
+def can_provider_run_attack_paths_scan(tenant_id: str, provider_id: int) -> bool:
+ with rls_transaction(tenant_id):
+ prowler_api_provider = ProwlerAPIProvider.objects.get(id=provider_id)
+
+ return is_provider_available(prowler_api_provider.provider)
+
+
+def create_attack_paths_scan(
+ tenant_id: str,
+ scan_id: str,
+ provider_id: int,
+) -> ProwlerAPIAttackPathsScan | None:
+ if not can_provider_run_attack_paths_scan(tenant_id, provider_id):
+ return None
+
+ with rls_transaction(tenant_id):
+ attack_paths_scan = ProwlerAPIAttackPathsScan.objects.create(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ scan_id=scan_id,
+ state=StateChoices.SCHEDULED,
+ started_at=datetime.now(tz=timezone.utc),
+ )
+ attack_paths_scan.save()
+
+ return attack_paths_scan
+
+
+def retrieve_attack_paths_scan(
+ tenant_id: str,
+ scan_id: str,
+) -> ProwlerAPIAttackPathsScan | None:
+ try:
+ with rls_transaction(tenant_id):
+ attack_paths_scan = ProwlerAPIAttackPathsScan.objects.get(
+ scan_id=scan_id,
+ )
+
+ return attack_paths_scan
+
+ except ProwlerAPIAttackPathsScan.DoesNotExist:
+ return None
+
+
+def starting_attack_paths_scan(
+ attack_paths_scan: ProwlerAPIAttackPathsScan,
+ task_id: str,
+ cartography_config: CartographyConfig,
+) -> None:
+ with rls_transaction(attack_paths_scan.tenant_id):
+ attack_paths_scan.task_id = task_id
+ attack_paths_scan.state = StateChoices.EXECUTING
+ attack_paths_scan.started_at = datetime.now(tz=timezone.utc)
+ attack_paths_scan.update_tag = cartography_config.update_tag
+ attack_paths_scan.graph_database = cartography_config.neo4j_database
+
+ attack_paths_scan.save(
+ update_fields=[
+ "task_id",
+ "state",
+ "started_at",
+ "update_tag",
+ "graph_database",
+ ]
+ )
+
+
+def finish_attack_paths_scan(
+ attack_paths_scan: ProwlerAPIAttackPathsScan,
+ state: StateChoices,
+ ingestion_exceptions: dict[str, Any],
+) -> None:
+ with rls_transaction(attack_paths_scan.tenant_id):
+ now = datetime.now(tz=timezone.utc)
+ duration = int((now - attack_paths_scan.started_at).total_seconds())
+
+ attack_paths_scan.state = state
+ attack_paths_scan.progress = 100
+ attack_paths_scan.completed_at = now
+ attack_paths_scan.duration = duration
+ attack_paths_scan.ingestion_exceptions = ingestion_exceptions
+
+ attack_paths_scan.save(
+ update_fields=[
+ "state",
+ "progress",
+ "completed_at",
+ "duration",
+ "ingestion_exceptions",
+ ]
+ )
+
+
+def update_attack_paths_scan_progress(
+ attack_paths_scan: ProwlerAPIAttackPathsScan,
+ progress: int,
+) -> None:
+ with rls_transaction(attack_paths_scan.tenant_id):
+ attack_paths_scan.progress = progress
+ attack_paths_scan.save(update_fields=["progress"])
+
+
+def get_old_attack_paths_scans(
+ tenant_id: str,
+ provider_id: str,
+ attack_paths_scan_id: str,
+) -> list[ProwlerAPIAttackPathsScan]:
+ """
+ An `old_attack_paths_scan` is any `completed` Attack Paths scan for the same provider,
+ with its graph database not deleted, excluding the current Attack Paths scan.
+ """
+
+ with rls_transaction(tenant_id):
+ completed_scans_qs = (
+ ProwlerAPIAttackPathsScan.objects.filter(
+ provider_id=provider_id,
+ state=StateChoices.COMPLETED,
+ is_graph_database_deleted=False,
+ )
+ .exclude(id=attack_paths_scan_id)
+ .all()
+ )
+
+ return list(completed_scans_qs)
+
+
+def update_old_attack_paths_scan(
+ old_attack_paths_scan: ProwlerAPIAttackPathsScan,
+) -> None:
+ with rls_transaction(old_attack_paths_scan.tenant_id):
+ old_attack_paths_scan.is_graph_database_deleted = True
+ old_attack_paths_scan.save(update_fields=["is_graph_database_deleted"])
+
+
+def get_provider_graph_database_names(tenant_id: str, provider_id: str) -> list[str]:
+ """
+ Return existing graph database names for a tenant/provider.
+
+ Note: For accesing the `AttackPathsScan` we need to use `all_objects` manager because the provider is soft-deleted.
+ """
+ with rls_transaction(tenant_id):
+ graph_databases_names_qs = (
+ ProwlerAPIAttackPathsScan.all_objects.filter(
+ ~Q(graph_database=""),
+ graph_database__isnull=False,
+ provider_id=provider_id,
+ is_graph_database_deleted=False,
+ )
+ .values_list("graph_database", flat=True)
+ .distinct()
+ )
+
+ return list(graph_databases_names_qs)
diff --git a/api/src/backend/tasks/jobs/attack_paths/providers.py b/api/src/backend/tasks/jobs/attack_paths/providers.py
new file mode 100644
index 0000000000..a0d4c44551
--- /dev/null
+++ b/api/src/backend/tasks/jobs/attack_paths/providers.py
@@ -0,0 +1,23 @@
+AVAILABLE_PROVIDERS: list[str] = [
+ "aws",
+]
+
+ROOT_NODE_LABELS: dict[str, str] = {
+ "aws": "AWSAccount",
+}
+
+NODE_UID_FIELDS: dict[str, str] = {
+ "aws": "arn",
+}
+
+
+def is_provider_available(provider_type: str) -> bool:
+ return provider_type in AVAILABLE_PROVIDERS
+
+
+def get_root_node_label(provider_type: str) -> str:
+ return ROOT_NODE_LABELS.get(provider_type, "UnknownProviderAccount")
+
+
+def get_node_uid_field(provider_type: str) -> str:
+ return NODE_UID_FIELDS.get(provider_type, "UnknownProviderUID")
diff --git a/api/src/backend/tasks/jobs/attack_paths/prowler.py b/api/src/backend/tasks/jobs/attack_paths/prowler.py
new file mode 100644
index 0000000000..7a92b9d760
--- /dev/null
+++ b/api/src/backend/tasks/jobs/attack_paths/prowler.py
@@ -0,0 +1,290 @@
+from collections import defaultdict
+from typing import Generator
+
+import neo4j
+from cartography.client.core.tx import run_write_query
+from cartography.config import Config as CartographyConfig
+from celery.utils.log import get_task_logger
+from config.env import env
+from tasks.jobs.attack_paths.providers import get_node_uid_field, get_root_node_label
+
+from api.db_router import READ_REPLICA_ALIAS
+from api.db_utils import rls_transaction
+from api.models import Finding, Provider, ResourceFindingMapping
+from prowler.config import config as ProwlerConfig
+
+logger = get_task_logger(__name__)
+
+BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000)
+
+INDEX_STATEMENTS = [
+ "CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.id);",
+ "CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.provider_uid);",
+ "CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.lastupdated);",
+ "CREATE INDEX prowler_finding_check_id IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.status);",
+]
+
+INSERT_STATEMENT_TEMPLATE = """
+ MATCH (account:__ROOT_NODE_LABEL__ {id: $provider_uid})
+ UNWIND $findings_data AS finding_data
+
+ OPTIONAL MATCH (account)-->(resource_by_uid)
+ WHERE resource_by_uid.__NODE_UID_FIELD__ = finding_data.resource_uid
+ WITH account, finding_data, resource_by_uid
+
+ OPTIONAL MATCH (account)-->(resource_by_id)
+ WHERE resource_by_uid IS NULL
+ AND resource_by_id.id = finding_data.resource_uid
+ WITH account, finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource
+ WHERE resource IS NOT NULL
+
+ MERGE (finding:ProwlerFinding {id: finding_data.id})
+ ON CREATE SET
+ finding.id = finding_data.id,
+ finding.uid = finding_data.uid,
+ finding.inserted_at = finding_data.inserted_at,
+ finding.updated_at = finding_data.updated_at,
+ finding.first_seen_at = finding_data.first_seen_at,
+ finding.scan_id = finding_data.scan_id,
+ finding.delta = finding_data.delta,
+ finding.status = finding_data.status,
+ finding.status_extended = finding_data.status_extended,
+ finding.severity = finding_data.severity,
+ finding.check_id = finding_data.check_id,
+ finding.check_title = finding_data.check_title,
+ finding.muted = finding_data.muted,
+ finding.muted_reason = finding_data.muted_reason,
+ finding.provider_uid = $provider_uid,
+ finding.firstseen = timestamp(),
+ finding.lastupdated = $last_updated,
+ finding._module_name = 'cartography:prowler',
+ finding._module_version = $prowler_version
+ ON MATCH SET
+ finding.status = finding_data.status,
+ finding.status_extended = finding_data.status_extended,
+ finding.lastupdated = $last_updated
+
+ MERGE (resource)-[rel:HAS_FINDING]->(finding)
+ ON CREATE SET
+ rel.provider_uid = $provider_uid,
+ rel.firstseen = timestamp(),
+ rel.lastupdated = $last_updated,
+ rel._module_name = 'cartography:prowler',
+ rel._module_version = $prowler_version
+ ON MATCH SET
+ rel.lastupdated = $last_updated
+"""
+
+CLEANUP_STATEMENT = """
+ MATCH (finding:ProwlerFinding {provider_uid: $provider_uid})
+ WHERE finding.lastupdated < $last_updated
+
+ WITH finding LIMIT $batch_size
+
+ DETACH DELETE finding
+
+ RETURN COUNT(finding) AS deleted_findings_count
+"""
+
+
+def create_indexes(neo4j_session: neo4j.Session) -> None:
+ """
+ Code based on Cartography version 0.122.0, specifically on `cartography.intel.create_indexes.run`.
+ """
+
+ logger.info("Creating indexes for Prowler Findings node types")
+ for statement in INDEX_STATEMENTS:
+ run_write_query(neo4j_session, statement)
+
+
+def analysis(
+ neo4j_session: neo4j.Session,
+ prowler_api_provider: Provider,
+ scan_id: str,
+ config: CartographyConfig,
+) -> None:
+ findings_data = get_provider_last_scan_findings(prowler_api_provider, scan_id)
+ load_findings(neo4j_session, findings_data, prowler_api_provider, config)
+ cleanup_findings(neo4j_session, prowler_api_provider, config)
+
+
+def get_provider_last_scan_findings(
+ prowler_api_provider: Provider,
+ scan_id: str,
+) -> Generator[list[dict[str, str]], None, None]:
+ """
+ Generator that yields batches of finding-resource pairs.
+
+ Two-step query approach per batch:
+ 1. Paginate findings for scan (single table, indexed by scan_id)
+ 2. Batch-fetch resource UIDs via mapping table (single join)
+ 3. Merge and yield flat structure for Neo4j
+
+ Memory efficient: never holds more than BATCH_SIZE findings in memory.
+ """
+
+ logger.info(
+ f"Starting findings fetch for scan {scan_id} (tenant {prowler_api_provider.tenant_id}) with batch size {BATCH_SIZE}"
+ )
+
+ iteration = 0
+ last_id = None
+
+ while True:
+ iteration += 1
+
+ with rls_transaction(prowler_api_provider.tenant_id, using=READ_REPLICA_ALIAS):
+ # Use all_objects to avoid the ActiveProviderManager's implicit JOIN
+ # through Scan -> Provider (to check is_deleted=False).
+ # The provider is already validated as active in this context.
+ qs = Finding.all_objects.filter(scan_id=scan_id).order_by("id")
+ if last_id is not None:
+ qs = qs.filter(id__gt=last_id)
+
+ findings_batch = list(
+ qs.values(
+ "id",
+ "uid",
+ "inserted_at",
+ "updated_at",
+ "first_seen_at",
+ "scan_id",
+ "delta",
+ "status",
+ "status_extended",
+ "severity",
+ "check_id",
+ "check_metadata__checktitle",
+ "muted",
+ "muted_reason",
+ )[:BATCH_SIZE]
+ )
+
+ logger.info(
+ f"Iteration #{iteration} fetched {len(findings_batch)} findings"
+ )
+
+ if not findings_batch:
+ logger.info(
+ f"No findings returned for iteration #{iteration}; stopping pagination"
+ )
+ break
+
+ last_id = findings_batch[-1]["id"]
+ enriched_batch = _enrich_and_flatten_batch(findings_batch)
+
+ # Yield outside the transaction
+ if enriched_batch:
+ yield enriched_batch
+
+ logger.info(f"Finished fetching findings for scan {scan_id}")
+
+
+def _enrich_and_flatten_batch(
+ findings_batch: list[dict],
+) -> list[dict[str, str]]:
+ """
+ Fetch resource UIDs for a batch of findings and return flat structure.
+
+ One finding with 3 resources becomes 3 dicts (same output format as before).
+ Must be called within an RLS transaction context.
+ """
+ finding_ids = [f["id"] for f in findings_batch]
+
+ # Single join: mapping -> resource
+ resource_mappings = ResourceFindingMapping.objects.filter(
+ finding_id__in=finding_ids
+ ).values_list("finding_id", "resource__uid")
+
+ # Build finding_id -> [resource_uids] mapping
+ finding_resources = defaultdict(list)
+ for finding_id, resource_uid in resource_mappings:
+ finding_resources[finding_id].append(resource_uid)
+
+ # Flatten: one dict per (finding, resource) pair
+ results = []
+ for f in findings_batch:
+ resource_uids = finding_resources.get(f["id"], [])
+
+ if not resource_uids:
+ continue
+
+ for resource_uid in resource_uids:
+ results.append(
+ {
+ "resource_uid": str(resource_uid),
+ "id": str(f["id"]),
+ "uid": f["uid"],
+ "inserted_at": f["inserted_at"],
+ "updated_at": f["updated_at"],
+ "first_seen_at": f["first_seen_at"],
+ "scan_id": str(f["scan_id"]),
+ "delta": f["delta"],
+ "status": f["status"],
+ "status_extended": f["status_extended"],
+ "severity": f["severity"],
+ "check_id": str(f["check_id"]),
+ "check_title": f["check_metadata__checktitle"],
+ "muted": f["muted"],
+ "muted_reason": f["muted_reason"],
+ }
+ )
+
+ return results
+
+
+def load_findings(
+ neo4j_session: neo4j.Session,
+ findings_batches: Generator[list[dict[str, str]], None, None],
+ prowler_api_provider: Provider,
+ config: CartographyConfig,
+) -> None:
+ replacements = {
+ "__ROOT_NODE_LABEL__": get_root_node_label(prowler_api_provider.provider),
+ "__NODE_UID_FIELD__": get_node_uid_field(prowler_api_provider.provider),
+ }
+ query = INSERT_STATEMENT_TEMPLATE
+ for replace_key, replace_value in replacements.items():
+ query = query.replace(replace_key, replace_value)
+
+ parameters = {
+ "provider_uid": str(prowler_api_provider.uid),
+ "last_updated": config.update_tag,
+ "prowler_version": ProwlerConfig.prowler_version,
+ }
+
+ batch_num = 0
+ total_records = 0
+ for batch in findings_batches:
+ batch_num += 1
+ batch_size = len(batch)
+ total_records += batch_size
+
+ parameters["findings_data"] = batch
+
+ logger.info(f"Loading findings batch {batch_num} ({batch_size} records)")
+ neo4j_session.run(query, parameters)
+
+ logger.info(f"Finished loading {total_records} records in {batch_num} batches")
+
+
+def cleanup_findings(
+ neo4j_session: neo4j.Session,
+ prowler_api_provider: Provider,
+ config: CartographyConfig,
+) -> None:
+ parameters = {
+ "provider_uid": str(prowler_api_provider.uid),
+ "last_updated": config.update_tag,
+ "batch_size": BATCH_SIZE,
+ }
+
+ batch = 1
+ deleted_count = 1
+ while deleted_count > 0:
+ logger.info(f"Cleaning findings batch {batch}")
+
+ result = neo4j_session.run(CLEANUP_STATEMENT, parameters)
+
+ deleted_count = result.single().get("deleted_findings_count", 0)
+ batch += 1
diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py
new file mode 100644
index 0000000000..9db4d6bf6e
--- /dev/null
+++ b/api/src/backend/tasks/jobs/attack_paths/scan.py
@@ -0,0 +1,197 @@
+import logging
+import time
+import asyncio
+
+from typing import Any, Callable
+
+from cartography.config import Config as CartographyConfig
+from cartography.intel import analysis as cartography_analysis
+from cartography.intel import create_indexes as cartography_create_indexes
+from cartography.intel import ontology as cartography_ontology
+from celery.utils.log import get_task_logger
+
+from api.attack_paths import database as graph_database
+from api.db_utils import rls_transaction
+from api.models import (
+ Provider as ProwlerAPIProvider,
+ StateChoices,
+)
+from api.utils import initialize_prowler_provider
+from tasks.jobs.attack_paths import aws, db_utils, prowler, utils
+
+# Without this Celery goes crazy with Cartography logging
+logging.getLogger("cartography").setLevel(logging.ERROR)
+logging.getLogger("neo4j").propagate = False
+
+logger = get_task_logger(__name__)
+
+CARTOGRAPHY_INGESTION_FUNCTIONS: dict[str, Callable] = {
+ "aws": aws.start_aws_ingestion,
+}
+
+
+def get_cartography_ingestion_function(provider_type: str) -> Callable | None:
+ return CARTOGRAPHY_INGESTION_FUNCTIONS.get(provider_type)
+
+
+def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
+ """
+ Code based on Cartography version 0.122.0, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`,
+ `cartography.sync.run_with_config` and `cartography.sync.Sync.run`.
+ """
+ ingestion_exceptions = {} # This will hold any exceptions raised during ingestion
+
+ # Prowler necessary objects
+ with rls_transaction(tenant_id):
+ prowler_api_provider = ProwlerAPIProvider.objects.get(scan__pk=scan_id)
+ prowler_sdk_provider = initialize_prowler_provider(prowler_api_provider)
+
+ # Attack Paths Scan necessary objects
+ cartography_ingestion_function = get_cartography_ingestion_function(
+ prowler_api_provider.provider
+ )
+ attack_paths_scan = db_utils.retrieve_attack_paths_scan(tenant_id, scan_id)
+
+ # Checks before starting the scan
+ if not cartography_ingestion_function:
+ ingestion_exceptions = {
+ "global_error": f"Provider {prowler_api_provider.provider} is not supported for Attack Paths scans"
+ }
+ if attack_paths_scan:
+ db_utils.finish_attack_paths_scan(
+ attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions
+ )
+
+ logger.warning(
+ f"Provider {prowler_api_provider.provider} is not supported for Attack Paths scans"
+ )
+ return ingestion_exceptions
+
+ else:
+ if not attack_paths_scan:
+ logger.warning(
+ f"No Attack Paths Scan found for scan {scan_id} and tenant {tenant_id}, let's create it then"
+ )
+ attack_paths_scan = db_utils.create_attack_paths_scan(
+ tenant_id, scan_id, prowler_api_provider.id
+ )
+
+ # While creating the Cartography configuration, attributes `neo4j_user` and `neo4j_password` are not really needed in this config object
+ cartography_config = CartographyConfig(
+ neo4j_uri=graph_database.get_uri(),
+ neo4j_database=graph_database.get_database_name(attack_paths_scan.id),
+ update_tag=int(time.time()),
+ )
+
+ # Starting the Attack Paths scan
+ db_utils.starting_attack_paths_scan(attack_paths_scan, task_id, cartography_config)
+
+ try:
+ logger.info(
+ f"Creating Neo4j database {cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}"
+ )
+
+ graph_database.create_database(cartography_config.neo4j_database)
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 1)
+
+ logger.info(
+ f"Starting Cartography ({attack_paths_scan.id}) for "
+ f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}"
+ )
+ with graph_database.get_session(
+ cartography_config.neo4j_database
+ ) as neo4j_session:
+ # Indexes creation
+ cartography_create_indexes.run(neo4j_session, cartography_config)
+ prowler.create_indexes(neo4j_session)
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2)
+
+ # The real scan, where iterates over cloud services
+ ingestion_exceptions = _call_within_event_loop(
+ cartography_ingestion_function,
+ neo4j_session,
+ cartography_config,
+ prowler_api_provider,
+ prowler_sdk_provider,
+ attack_paths_scan,
+ )
+
+ # Post-processing: Just keeping it to be more Cartography compliant
+ logger.info(
+ f"Syncing Cartography ontology for AWS account {prowler_api_provider.uid}"
+ )
+ cartography_ontology.run(neo4j_session, cartography_config)
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 95)
+
+ logger.info(
+ f"Syncing Cartography analysis for AWS account {prowler_api_provider.uid}"
+ )
+ cartography_analysis.run(neo4j_session, cartography_config)
+ db_utils.update_attack_paths_scan_progress(attack_paths_scan, 96)
+
+ # Adding Prowler nodes and relationships
+ logger.info(
+ f"Syncing Prowler analysis for AWS account {prowler_api_provider.uid}"
+ )
+ prowler.analysis(
+ neo4j_session, prowler_api_provider, scan_id, cartography_config
+ )
+
+ logger.info(
+ f"Clearing Neo4j cache for database {cartography_config.neo4j_database}"
+ )
+ graph_database.clear_cache(cartography_config.neo4j_database)
+
+ logger.info(
+ f"Completed Cartography ({attack_paths_scan.id}) for "
+ f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}"
+ )
+
+ # Handling databases changes
+ old_attack_paths_scans = db_utils.get_old_attack_paths_scans(
+ prowler_api_provider.tenant_id,
+ prowler_api_provider.id,
+ attack_paths_scan.id,
+ )
+ for old_attack_paths_scan in old_attack_paths_scans:
+ graph_database.drop_database(old_attack_paths_scan.graph_database)
+ db_utils.update_old_attack_paths_scan(old_attack_paths_scan)
+
+ db_utils.finish_attack_paths_scan(
+ attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions
+ )
+ return ingestion_exceptions
+
+ except Exception as e:
+ exception_message = utils.stringify_exception(e, "Cartography failed")
+ logger.error(exception_message)
+ ingestion_exceptions["global_cartography_error"] = exception_message
+
+ # Handling databases changes
+ graph_database.drop_database(cartography_config.neo4j_database)
+ db_utils.finish_attack_paths_scan(
+ attack_paths_scan, StateChoices.FAILED, ingestion_exceptions
+ )
+ raise
+
+
+def _call_within_event_loop(fn, *args, **kwargs):
+ """
+ Cartography needs a running event loop, so assuming there is none (Celery task or even regular DRF endpoint),
+ let's create a new one and set it as the current event loop for this thread.
+ """
+
+ loop = asyncio.new_event_loop()
+ try:
+ asyncio.set_event_loop(loop)
+ return fn(*args, **kwargs)
+
+ finally:
+ try:
+ loop.run_until_complete(loop.shutdown_asyncgens())
+
+ except Exception as e:
+ logger.warning(f"Failed to shutdown async generators cleanly: {e}")
+
+ loop.close()
+ asyncio.set_event_loop(None)
diff --git a/api/src/backend/tasks/jobs/attack_paths/utils.py b/api/src/backend/tasks/jobs/attack_paths/utils.py
new file mode 100644
index 0000000000..0c737d4158
--- /dev/null
+++ b/api/src/backend/tasks/jobs/attack_paths/utils.py
@@ -0,0 +1,10 @@
+import traceback
+
+from datetime import datetime, timezone
+
+
+def stringify_exception(exception: Exception, context: str) -> str:
+ timestamp = datetime.now(tz=timezone.utc)
+ exception_traceback = traceback.TracebackException.from_exception(exception)
+ traceback_string = "".join(exception_traceback.format())
+ return f"{timestamp} - {context}\n{traceback_string}"
diff --git a/api/src/backend/tasks/jobs/backfill.py b/api/src/backend/tasks/jobs/backfill.py
index 851efe4e4a..d9985afafb 100644
--- a/api/src/backend/tasks/jobs/backfill.py
+++ b/api/src/backend/tasks/jobs/backfill.py
@@ -2,13 +2,13 @@ from collections import defaultdict
from datetime import timedelta
from celery.utils.log import get_task_logger
-from django.db.models import Sum
+from django.db.models import OuterRef, Subquery, Sum
from django.utils import timezone
from tasks.jobs.queries import (
COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL,
COMPLIANCE_UPSERT_TENANT_SUMMARY_ALL_SQL,
)
-from tasks.jobs.scan import aggregate_category_counts
+from tasks.jobs.scan import aggregate_category_counts, aggregate_resource_group_counts
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import (
@@ -28,6 +28,7 @@ from api.models import (
ResourceScanSummary,
Scan,
ScanCategorySummary,
+ ScanGroupSummary,
ScanSummary,
StateChoices,
)
@@ -356,6 +357,92 @@ def backfill_scan_category_summaries(tenant_id: str, scan_id: str):
return {"status": "backfilled", "categories_count": len(category_counts)}
+def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str):
+ """
+ Backfill ScanGroupSummary for a completed scan.
+
+ Aggregates resource group counts from all findings in the scan and creates
+ one ScanGroupSummary row per (resource_group, severity) combination.
+
+ Args:
+ tenant_id: Target tenant UUID
+ scan_id: Scan UUID to backfill
+
+ Returns:
+ dict: Status indicating whether backfill was performed
+ """
+ with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
+ if ScanGroupSummary.objects.filter(
+ tenant_id=tenant_id, scan_id=scan_id
+ ).exists():
+ return {"status": "already backfilled"}
+
+ if not Scan.objects.filter(
+ tenant_id=tenant_id,
+ id=scan_id,
+ state__in=(StateChoices.COMPLETED, StateChoices.FAILED),
+ ).exists():
+ return {"status": "scan is not completed"}
+
+ resource_group_counts: dict[tuple[str, str], dict[str, int]] = {}
+ group_resources_cache: dict[str, set] = {}
+ # Get findings with their first resource UID via annotation
+ resource_uid_subquery = ResourceFindingMapping.objects.filter(
+ finding_id=OuterRef("id"), tenant_id=tenant_id
+ ).values("resource__uid")[:1]
+
+ for finding in (
+ Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id)
+ .annotate(resource_uid=Subquery(resource_uid_subquery))
+ .values(
+ "resource_groups",
+ "severity",
+ "status",
+ "delta",
+ "muted",
+ "resource_uid",
+ )
+ ):
+ aggregate_resource_group_counts(
+ resource_group=finding.get("resource_groups"),
+ severity=finding.get("severity"),
+ status=finding.get("status"),
+ delta=finding.get("delta"),
+ muted=finding.get("muted", False),
+ resource_uid=finding.get("resource_uid") or "",
+ cache=resource_group_counts,
+ group_resources_cache=group_resources_cache,
+ )
+
+ if not resource_group_counts:
+ return {"status": "no resource groups to backfill"}
+
+ # Compute group-level resource counts (same value for all severity rows in a group)
+ group_resource_counts = {
+ grp: len(uids) for grp, uids in group_resources_cache.items()
+ }
+ resource_group_summaries = [
+ ScanGroupSummary(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ resource_group=grp,
+ severity=severity,
+ total_findings=counts["total"],
+ failed_findings=counts["failed"],
+ new_failed_findings=counts["new_failed"],
+ resources_count=group_resource_counts.get(grp, 0),
+ )
+ for (grp, severity), counts in resource_group_counts.items()
+ ]
+
+ with rls_transaction(tenant_id):
+ ScanGroupSummary.objects.bulk_create(
+ resource_group_summaries, batch_size=500, ignore_conflicts=True
+ )
+
+ return {"status": "backfilled", "resource_groups_count": len(resource_group_counts)}
+
+
def backfill_provider_compliance_scores(tenant_id: str) -> dict:
"""
Backfill ProviderComplianceScore from latest completed scan per provider.
diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py
index d72b8de40e..6eee63de6a 100644
--- a/api/src/backend/tasks/jobs/deletion.py
+++ b/api/src/backend/tasks/jobs/deletion.py
@@ -1,9 +1,19 @@
from celery.utils.log import get_task_logger
from django.db import DatabaseError
+from api.attack_paths import database as graph_database
from api.db_router import MainRouter
from api.db_utils import batch_delete, rls_transaction
-from api.models import Finding, Provider, Resource, Scan, ScanSummary, Tenant
+from api.models import (
+ AttackPathsScan,
+ Finding,
+ Provider,
+ Resource,
+ Scan,
+ ScanSummary,
+ Tenant,
+)
+from tasks.jobs.attack_paths.db_utils import get_provider_graph_database_names
logger = get_task_logger(__name__)
@@ -23,16 +33,27 @@ def delete_provider(tenant_id: str, pk: str):
Raises:
Provider.DoesNotExist: If no instance with the provided primary key exists.
"""
+ # Delete the Attack Paths' graph databases related to the provider
+ graph_database_names = get_provider_graph_database_names(tenant_id, pk)
+ try:
+ for graph_database_name in graph_database_names:
+ graph_database.drop_database(graph_database_name)
+ except graph_database.GraphDatabaseQueryException as gdb_error:
+ logger.error(f"Error deleting Provider databases: {gdb_error}")
+ raise
+
+ # Get all provider related data and delete them in batches
with rls_transaction(tenant_id):
instance = Provider.all_objects.get(pk=pk)
- deletion_summary = {}
deletion_steps = [
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
("Findings", Finding.all_objects.filter(scan__provider=instance)),
("Resources", Resource.all_objects.filter(provider=instance)),
("Scans", Scan.all_objects.filter(provider=instance)),
+ ("AttackPathsScans", AttackPathsScan.all_objects.filter(provider=instance)),
]
+ deletion_summary = {}
for step_name, queryset in deletion_steps:
try:
_, step_summary = batch_delete(tenant_id, queryset)
@@ -48,6 +69,7 @@ def delete_provider(tenant_id: str, pk: str):
except DatabaseError as db_error:
logger.error(f"Error deleting Provider: {db_error}")
raise
+
return deletion_summary
diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py
index 1fbf9161d6..de022e9afd 100644
--- a/api/src/backend/tasks/jobs/report.py
+++ b/api/src/backend/tasks/jobs/report.py
@@ -1,1023 +1,25 @@
-import io
-import os
-from collections import defaultdict
-from functools import partial
from pathlib import Path
from shutil import rmtree
-import matplotlib.pyplot as plt
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
-from reportlab.lib import colors
-from reportlab.lib.enums import TA_CENTER
-from reportlab.lib.pagesizes import letter
-from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
-from reportlab.lib.units import inch
-from reportlab.pdfbase import pdfmetrics
-from reportlab.pdfbase.ttfonts import TTFont
-from reportlab.pdfgen import canvas
-from reportlab.platypus import (
- Image,
- PageBreak,
- Paragraph,
- SimpleDocTemplate,
- Spacer,
- Table,
- TableStyle,
-)
from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3
-from tasks.jobs.threatscore import compute_threatscore_metrics
-from tasks.jobs.threatscore_utils import (
- _aggregate_requirement_statistics_from_database,
- _calculate_requirements_data_from_statistics,
- _load_findings_for_requirement_checks,
+from tasks.jobs.reports import (
+ FRAMEWORK_REGISTRY,
+ ENSReportGenerator,
+ NIS2ReportGenerator,
+ ThreatScoreReportGenerator,
)
+from tasks.jobs.threatscore import compute_threatscore_metrics
+from tasks.jobs.threatscore_utils import _aggregate_requirement_statistics_from_database
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
-from api.models import Provider, ScanSummary, StatusChoices, ThreatScoreSnapshot
-from api.utils import initialize_prowler_provider
-from prowler.lib.check.compliance_models import Compliance
+from api.models import Provider, ScanSummary, ThreatScoreSnapshot
from prowler.lib.outputs.finding import Finding as FindingOutput
-pdfmetrics.registerFont(
- TTFont(
- "PlusJakartaSans",
- os.path.join(
- os.path.dirname(__file__), "../assets/fonts/PlusJakartaSans-Regular.ttf"
- ),
- )
-)
-
-pdfmetrics.registerFont(
- TTFont(
- "FiraCode",
- os.path.join(os.path.dirname(__file__), "../assets/fonts/FiraCode-Regular.ttf"),
- )
-)
-
logger = get_task_logger(__name__)
-# Color constants
-COLOR_PROWLER_DARK_GREEN = colors.Color(0.1, 0.5, 0.2)
-COLOR_BLUE = colors.Color(0.2, 0.4, 0.6)
-COLOR_LIGHT_BLUE = colors.Color(0.3, 0.5, 0.7)
-COLOR_LIGHTER_BLUE = colors.Color(0.4, 0.6, 0.8)
-COLOR_BG_BLUE = colors.Color(0.95, 0.97, 1.0)
-COLOR_BG_LIGHT_BLUE = colors.Color(0.98, 0.99, 1.0)
-COLOR_GRAY = colors.Color(0.2, 0.2, 0.2)
-COLOR_LIGHT_GRAY = colors.Color(0.9, 0.9, 0.9)
-COLOR_BORDER_GRAY = colors.Color(0.7, 0.8, 0.9)
-COLOR_GRID_GRAY = colors.Color(0.7, 0.7, 0.7)
-COLOR_DARK_GRAY = colors.Color(0.4, 0.4, 0.4)
-COLOR_HEADER_DARK = colors.Color(0.1, 0.3, 0.5)
-COLOR_HEADER_MEDIUM = colors.Color(0.15, 0.35, 0.55)
-COLOR_WHITE = colors.white
-
-# Risk and status colors
-COLOR_HIGH_RISK = colors.Color(0.8, 0.2, 0.2)
-COLOR_MEDIUM_RISK = colors.Color(0.9, 0.6, 0.2)
-COLOR_LOW_RISK = colors.Color(0.9, 0.9, 0.2)
-COLOR_SAFE = colors.Color(0.2, 0.8, 0.2)
-
-# ENS specific colors
-COLOR_ENS_ALTO = colors.Color(0.8, 0.2, 0.2)
-COLOR_ENS_MEDIO = colors.Color(0.98, 0.75, 0.13)
-COLOR_ENS_BAJO = colors.Color(0.06, 0.72, 0.51)
-COLOR_ENS_OPCIONAL = colors.Color(0.42, 0.45, 0.50)
-COLOR_ENS_TIPO = colors.Color(0.2, 0.4, 0.6)
-COLOR_ENS_AUTO = colors.Color(0.30, 0.69, 0.31)
-COLOR_ENS_MANUAL = colors.Color(0.96, 0.60, 0.0)
-
-# NIS2 specific colors
-COLOR_NIS2_PRIMARY = colors.Color(0.12, 0.23, 0.54) # EU Blue #1E3A8A
-COLOR_NIS2_SECONDARY = colors.Color(0.23, 0.51, 0.96) # Light Blue #3B82F6
-COLOR_NIS2_BG_BLUE = colors.Color(0.96, 0.97, 0.99) # Very light blue background
-
-# Chart colors
-CHART_COLOR_GREEN_1 = "#4CAF50"
-CHART_COLOR_GREEN_2 = "#8BC34A"
-CHART_COLOR_YELLOW = "#FFEB3B"
-CHART_COLOR_ORANGE = "#FF9800"
-CHART_COLOR_RED = "#F44336"
-CHART_COLOR_BLUE = "#2196F3"
-
-# ENS dimension mappings
-DIMENSION_MAPPING = {
- "trazabilidad": ("T", colors.Color(0.26, 0.52, 0.96)),
- "autenticidad": ("A", colors.Color(0.30, 0.69, 0.31)),
- "integridad": ("I", colors.Color(0.61, 0.15, 0.69)),
- "confidencialidad": ("C", colors.Color(0.96, 0.26, 0.21)),
- "disponibilidad": ("D", colors.Color(1.0, 0.60, 0.0)),
-}
-
-# ENS tipo icons
-TIPO_ICONS = {
- "requisito": "β οΈ",
- "refuerzo": "π‘οΈ",
- "recomendacion": "π‘",
- "medida": "π",
-}
-
-# Dimension names for charts
-DIMENSION_NAMES = [
- "Trazabilidad",
- "Autenticidad",
- "Integridad",
- "Confidencialidad",
- "Disponibilidad",
-]
-
-DIMENSION_KEYS = [
- "trazabilidad",
- "autenticidad",
- "integridad",
- "confidencialidad",
- "disponibilidad",
-]
-
-# ENS nivel order
-ENS_NIVEL_ORDER = ["alto", "medio", "bajo", "opcional"]
-
-# ENS tipo order
-ENS_TIPO_ORDER = ["requisito", "refuerzo", "recomendacion", "medida"]
-
-# ThreatScore expected sections
-THREATSCORE_SECTIONS = [
- "1. IAM",
- "2. Attack Surface",
- "3. Logging and Monitoring",
- "4. Encryption",
-]
-
-# NIS2 main sections (simplified for chart display)
-NIS2_SECTIONS = [
- "1", # Policy on Security
- "2", # Risk Management
- "3", # Incident Handling
- "4", # Business Continuity
- "5", # Supply Chain Security
- "6", # Acquisition & Development
- "7", # Effectiveness Assessment
- "9", # Cryptography
- "11", # Access Control
- "12", # Asset Management
-]
-
-# Table column widths (in inches)
-COL_WIDTH_SMALL = 0.4 * inch
-COL_WIDTH_MEDIUM = 0.9 * inch
-COL_WIDTH_LARGE = 1.5 * inch
-COL_WIDTH_XLARGE = 2 * inch
-COL_WIDTH_XXLARGE = 3 * inch
-
-# Common padding values
-PADDING_SMALL = 4
-PADDING_MEDIUM = 6
-PADDING_LARGE = 8
-PADDING_XLARGE = 10
-
-
-# Cache for PDF styles to avoid recreating them on every call
-_PDF_STYLES_CACHE: dict[str, ParagraphStyle] | None = None
-
-
-# Helper functions for performance optimization
-def _get_color_for_risk_level(risk_level: int) -> colors.Color:
- """Get color based on risk level using optimized lookup."""
- if risk_level >= 4:
- return COLOR_HIGH_RISK
- elif risk_level >= 3:
- return COLOR_MEDIUM_RISK
- elif risk_level >= 2:
- return COLOR_LOW_RISK
- return COLOR_SAFE
-
-
-def _get_color_for_weight(weight: int) -> colors.Color:
- """Get color based on weight using optimized lookup."""
- if weight > 100:
- return COLOR_HIGH_RISK
- elif weight > 50:
- return COLOR_LOW_RISK
- return COLOR_SAFE
-
-
-def _get_color_for_compliance(percentage: float) -> colors.Color:
- """Get color based on compliance percentage."""
- if percentage >= 80:
- return COLOR_SAFE
- elif percentage >= 60:
- return COLOR_LOW_RISK
- return COLOR_HIGH_RISK
-
-
-def _get_chart_color_for_percentage(percentage: float) -> str:
- """Get chart color string based on percentage."""
- if percentage >= 80:
- return CHART_COLOR_GREEN_1
- elif percentage >= 60:
- return CHART_COLOR_GREEN_2
- elif percentage >= 40:
- return CHART_COLOR_YELLOW
- elif percentage >= 20:
- return CHART_COLOR_ORANGE
- return CHART_COLOR_RED
-
-
-def _get_ens_nivel_color(nivel: str) -> colors.Color:
- """Get ENS nivel color using optimized lookup."""
- nivel_lower = nivel.lower()
- if nivel_lower == "alto":
- return COLOR_ENS_ALTO
- elif nivel_lower == "medio":
- return COLOR_ENS_MEDIO
- elif nivel_lower == "bajo":
- return COLOR_ENS_BAJO
- return COLOR_ENS_OPCIONAL
-
-
-def _safe_getattr(obj, attr: str, default: str = "N/A") -> str:
- """Optimized getattr with default value."""
- return getattr(obj, attr, default)
-
-
-def _create_info_table_style() -> TableStyle:
- """Create a reusable table style for information/metadata tables.
-
- ReportLab TableStyle coordinate system:
- - Format: (COMMAND, (start_col, start_row), (end_col, end_row), value)
- - Coordinates use (column, row) format, starting at (0, 0) for top-left cell
- - Negative indices work like Python slicing: -1 means "last row/column"
- - (0, 0) to (0, -1) = entire first column (all rows)
- - (0, 0) to (-1, 0) = entire first row (all columns)
- - (0, 0) to (-1, -1) = entire table
- - Styles are applied in order; later rules override earlier ones
- """
- return TableStyle(
- [
- # Column 0 (labels): blue background with white text
- ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE),
- ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE),
- ("FONTNAME", (0, 0), (0, -1), "FiraCode"),
- # Column 1 (values): light blue background with gray text
- ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE),
- ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY),
- ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
- # Apply to entire table
- ("ALIGN", (0, 0), (-1, -1), "LEFT"),
- ("VALIGN", (0, 0), (-1, -1), "TOP"),
- ("FONTSIZE", (0, 0), (-1, -1), 11),
- ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY),
- ("LEFTPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
- ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
- ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ]
- )
-
-
-def _create_header_table_style(header_color: colors.Color = None) -> TableStyle:
- """Create a reusable table style for tables with headers.
-
- ReportLab TableStyle coordinate system:
- - Format: (COMMAND, (start_col, start_row), (end_col, end_row), value)
- - (0, 0) to (-1, 0) = entire first row (header row)
- - (1, 1) to (-1, -1) = all data cells (excludes header row and first column)
- - See _create_info_table_style() for full coordinate system documentation
- """
- if header_color is None:
- header_color = COLOR_BLUE
-
- return TableStyle(
- [
- # Header row (row 0): colored background with white text
- ("BACKGROUND", (0, 0), (-1, 0), header_color),
- ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- ("FONTSIZE", (0, 0), (-1, 0), 10),
- # Apply to entire table
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- # Data cells (excluding header): smaller font
- ("FONTSIZE", (1, 1), (-1, -1), 9),
- # Apply to entire table
- ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY),
- ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
- ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
- ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
- ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
- ]
- )
-
-
-def _create_findings_table_style() -> TableStyle:
- """Create a reusable table style for findings tables.
-
- ReportLab TableStyle coordinate system:
- - Format: (COMMAND, (start_col, start_row), (end_col, end_row), value)
- - (0, 0) to (-1, 0) = entire first row (header row)
- - (0, 0) to (0, 0) = only the top-left cell
- - See _create_info_table_style() for full coordinate system documentation
- """
- return TableStyle(
- [
- # Header row (row 0): colored background with white text
- ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
- ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- # Only top-left cell centered (for index/number column)
- ("ALIGN", (0, 0), (0, 0), "CENTER"),
- # Apply to entire table
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 9),
- ("GRID", (0, 0), (-1, -1), 0.1, COLOR_BORDER_GRAY),
- # Remove padding only from top-left cell
- ("LEFTPADDING", (0, 0), (0, 0), 0),
- ("RIGHTPADDING", (0, 0), (0, 0), 0),
- # Apply to entire table
- ("TOPPADDING", (0, 0), (-1, -1), PADDING_SMALL),
- ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_SMALL),
- ]
- )
-
-
-def _create_pdf_styles() -> dict[str, ParagraphStyle]:
- """
- Create and return PDF paragraph styles used throughout the report.
-
- Styles are cached on first call to improve performance.
-
- Returns:
- dict[str, ParagraphStyle]: A dictionary containing the following styles:
- - 'title': Title style with prowler green color
- - 'h1': Heading 1 style with blue color and background
- - 'h2': Heading 2 style with light blue color
- - 'h3': Heading 3 style for sub-headings
- - 'normal': Normal text style with left indent
- - 'normal_center': Normal text style without indent
- """
- global _PDF_STYLES_CACHE
-
- if _PDF_STYLES_CACHE is not None:
- return _PDF_STYLES_CACHE
-
- styles = getSampleStyleSheet()
-
- title_style = ParagraphStyle(
- "CustomTitle",
- parent=styles["Title"],
- fontSize=24,
- textColor=COLOR_PROWLER_DARK_GREEN,
- spaceAfter=20,
- fontName="PlusJakartaSans",
- alignment=TA_CENTER,
- )
-
- h1 = ParagraphStyle(
- "CustomH1",
- parent=styles["Heading1"],
- fontSize=18,
- textColor=COLOR_BLUE,
- spaceBefore=20,
- spaceAfter=12,
- fontName="PlusJakartaSans",
- leftIndent=0,
- borderWidth=2,
- borderColor=COLOR_BLUE,
- borderPadding=PADDING_LARGE,
- backColor=COLOR_BG_BLUE,
- )
-
- h2 = ParagraphStyle(
- "CustomH2",
- parent=styles["Heading2"],
- fontSize=14,
- textColor=COLOR_LIGHT_BLUE,
- spaceBefore=15,
- spaceAfter=8,
- fontName="PlusJakartaSans",
- leftIndent=10,
- borderWidth=1,
- borderColor=COLOR_BORDER_GRAY,
- borderPadding=5,
- backColor=COLOR_BG_LIGHT_BLUE,
- )
-
- h3 = ParagraphStyle(
- "CustomH3",
- parent=styles["Heading3"],
- fontSize=12,
- textColor=COLOR_LIGHTER_BLUE,
- spaceBefore=10,
- spaceAfter=6,
- fontName="PlusJakartaSans",
- leftIndent=20,
- )
-
- normal = ParagraphStyle(
- "CustomNormal",
- parent=styles["Normal"],
- fontSize=10,
- textColor=COLOR_GRAY,
- spaceBefore=PADDING_SMALL,
- spaceAfter=PADDING_SMALL,
- leftIndent=30,
- fontName="PlusJakartaSans",
- )
-
- normal_center = ParagraphStyle(
- "CustomNormalCenter",
- parent=styles["Normal"],
- fontSize=10,
- textColor=COLOR_GRAY,
- fontName="PlusJakartaSans",
- )
-
- _PDF_STYLES_CACHE = {
- "title": title_style,
- "h1": h1,
- "h2": h2,
- "h3": h3,
- "normal": normal,
- "normal_center": normal_center,
- }
-
- return _PDF_STYLES_CACHE
-
-
-def _create_risk_component(risk_level: int, weight: int, score: int = 0) -> Table:
- """
- Create a visual risk component table for the PDF report.
-
- Args:
- risk_level (int): The risk level (0-5), where higher values indicate higher risk.
- weight (int): The weight of the risk component.
- score (int): The calculated score. Defaults to 0.
-
- Returns:
- Table: A ReportLab Table object with colored cells representing risk, weight, and score.
- """
- risk_color = _get_color_for_risk_level(risk_level)
- weight_color = _get_color_for_weight(weight)
-
- data = [
- [
- "Risk Level:",
- str(risk_level),
- "Weight:",
- str(weight),
- "Score:",
- str(score),
- ]
- ]
-
- table = Table(
- data,
- colWidths=[
- 0.8 * inch,
- COL_WIDTH_SMALL,
- 0.6 * inch,
- COL_WIDTH_SMALL,
- 0.5 * inch,
- COL_WIDTH_SMALL,
- ],
- )
-
- table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY),
- ("BACKGROUND", (1, 0), (1, 0), risk_color),
- ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
- ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
- ("BACKGROUND", (2, 0), (2, 0), COLOR_LIGHT_GRAY),
- ("BACKGROUND", (3, 0), (3, 0), weight_color),
- ("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE),
- ("FONTNAME", (3, 0), (3, 0), "FiraCode"),
- ("BACKGROUND", (4, 0), (4, 0), COLOR_LIGHT_GRAY),
- ("BACKGROUND", (5, 0), (5, 0), COLOR_DARK_GRAY),
- ("TEXTCOLOR", (5, 0), (5, 0), COLOR_WHITE),
- ("FONTNAME", (5, 0), (5, 0), "FiraCode"),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 10),
- ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
- ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
- ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
- ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ]
- )
- )
-
- return table
-
-
-def _create_status_component(status: str) -> Table:
- """
- Create a visual status component with colored background.
-
- Args:
- status (str): The status value (e.g., "PASS", "FAIL", "MANUAL").
-
- Returns:
- Table: A ReportLab Table object displaying the status with appropriate color coding.
- """
- status_upper = status.upper()
- if status_upper == "PASS":
- status_color = COLOR_SAFE
- elif status_upper == "FAIL":
- status_color = COLOR_HIGH_RISK
- else:
- status_color = COLOR_DARK_GRAY
-
- data = [["State:", status_upper]]
-
- table = Table(data, colWidths=[0.6 * inch, 0.8 * inch])
-
- table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY),
- ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"),
- ("BACKGROUND", (1, 0), (1, 0), status_color),
- ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
- ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 12),
- ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
- ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("TOPPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
- ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
- ]
- )
- )
-
- return table
-
-
-def _create_ens_nivel_badge(nivel: str) -> Table:
- """
- Create a visual badge for ENS requirement level (Nivel).
-
- Args:
- nivel (str): The level value (e.g., "alto", "medio", "bajo", "opcional").
-
- Returns:
- Table: A ReportLab Table object displaying the level with appropriate color coding.
- """
- nivel_color = _get_ens_nivel_color(nivel)
- data = [[f"Nivel: {nivel.upper()}"]]
-
- table = Table(data, colWidths=[1.4 * inch])
-
- table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), nivel_color),
- ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE),
- ("FONTNAME", (0, 0), (0, 0), "FiraCode"),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 11),
- ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
- ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ]
- )
- )
-
- return table
-
-
-def _create_ens_tipo_badge(tipo: str) -> Table:
- """
- Create a visual badge for ENS requirement type (Tipo).
-
- Args:
- tipo (str): The type value (e.g., "requisito", "refuerzo", "recomendacion", "medida").
-
- Returns:
- Table: A ReportLab Table object displaying the type with appropriate styling.
- """
- tipo_lower = tipo.lower()
- icon = TIPO_ICONS.get(tipo_lower, "")
-
- data = [[f"{icon} {tipo.capitalize()}"]]
-
- table = Table(data, colWidths=[1.8 * inch])
-
- table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), COLOR_ENS_TIPO),
- ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE),
- ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 11),
- ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
- ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
- ]
- )
- )
-
- return table
-
-
-def _create_ens_dimension_badges(dimensiones: list[str]) -> Table:
- """
- Create visual badges for ENS security dimensions.
-
- Args:
- dimensiones (list[str]): List of dimension names (e.g., ["trazabilidad", "autenticidad"]).
-
- Returns:
- Table: A ReportLab Table object with color-coded badges for each dimension.
- """
- badges = [
- DIMENSION_MAPPING[dimension.lower()]
- for dimension in dimensiones
- if dimension.lower() in DIMENSION_MAPPING
- ]
-
- if not badges:
- data = [["N/A"]]
- table = Table(data, colWidths=[1 * inch])
- table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("FONTSIZE", (0, 0), (-1, -1), 10),
- ]
- )
- )
- return table
-
- data = [[badge[0] for badge in badges]]
- col_widths = [COL_WIDTH_SMALL] * len(badges)
-
- table = Table(data, colWidths=col_widths)
-
- styles = [
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTNAME", (0, 0), (-1, -1), "FiraCode"),
- ("FONTSIZE", (0, 0), (-1, -1), 10),
- ("TEXTCOLOR", (0, 0), (-1, -1), COLOR_WHITE),
- ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
- ("LEFTPADDING", (0, 0), (-1, -1), PADDING_SMALL),
- ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_SMALL),
- ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
- ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
- ]
-
- for idx, (_, badge_color) in enumerate(badges):
- styles.append(("BACKGROUND", (idx, 0), (idx, 0), badge_color))
-
- table.setStyle(TableStyle(styles))
-
- return table
-
-
-def _create_section_score_chart(
- requirements_list: list[dict], attributes_by_requirement_id: dict
-) -> io.BytesIO:
- """
- Create a bar chart showing compliance score by section using ThreatScore formula.
-
- Args:
- requirements_list (list[dict]): List of requirement dictionaries with status and findings data.
- attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes including risk level and weight.
-
- Returns:
- io.BytesIO: A BytesIO buffer containing the chart image in PNG format.
- """
- # Initialize all expected sections with default values
- sections_data = {
- section: {
- "numerator": 0,
- "denominator": 0,
- "has_findings": False,
- }
- for section in THREATSCORE_SECTIONS
- }
-
- # Collect data from requirements
- for requirement in requirements_list:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(requirement_id, {})
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if not metadata:
- continue
-
- m = metadata[0]
- section = _safe_getattr(m, "Section", "Unknown")
-
- # Add section if not in expected list (for flexibility)
- if section not in sections_data:
- sections_data[section] = {
- "numerator": 0,
- "denominator": 0,
- "has_findings": False,
- }
-
- # Get findings data
- passed_findings = requirement["attributes"].get("passed_findings", 0)
- total_findings = requirement["attributes"].get("total_findings", 0)
-
- if total_findings > 0:
- sections_data[section]["has_findings"] = True
- risk_level = _safe_getattr(m, "LevelOfRisk", 0)
- weight = _safe_getattr(m, "Weight", 0)
-
- # Calculate using ThreatScore formula from UI
- rate_i = passed_findings / total_findings
- rfac_i = 1 + 0.25 * risk_level
-
- sections_data[section]["numerator"] += (
- rate_i * total_findings * weight * rfac_i
- )
- sections_data[section]["denominator"] += total_findings * weight * rfac_i
-
- # Calculate percentages
- section_names = []
- compliance_percentages = []
-
- for section, data in sections_data.items():
- if data["has_findings"] and data["denominator"] > 0:
- compliance_percentage = (data["numerator"] / data["denominator"]) * 100
- else:
- compliance_percentage = 100 # No findings = 100% (PASS)
-
- section_names.append(section)
- compliance_percentages.append(compliance_percentage)
-
- # Sort alphabetically by section name
- sorted_data = sorted(zip(section_names, compliance_percentages), key=lambda x: x[0])
- if not sorted_data:
- section_names, compliance_percentages = [], []
- else:
- section_names, compliance_percentages = zip(*sorted_data)
-
- # Generate chart
- fig, ax = plt.subplots(figsize=(12, 8))
-
- # Use helper function for color selection
- colors_list = [_get_chart_color_for_percentage(p) for p in compliance_percentages]
-
- bars = ax.bar(section_names, compliance_percentages, color=colors_list)
-
- ax.set_ylabel("Compliance Score (%)", fontsize=12)
- ax.set_xlabel("Section", fontsize=12)
- ax.set_ylim(0, 100)
-
- for bar, percentage in zip(bars, compliance_percentages):
- height = bar.get_height()
- ax.text(
- bar.get_x() + bar.get_width() / 2.0,
- height + 1,
- f"{percentage:.1f}%",
- ha="center",
- va="bottom",
- fontweight="bold",
- )
-
- plt.xticks(rotation=45, ha="right")
- ax.grid(True, alpha=0.3, axis="y")
- plt.tight_layout()
-
- buffer = io.BytesIO()
- try:
- plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight")
- buffer.seek(0)
- finally:
- plt.close(fig)
-
- return buffer
-
-
-def _add_pdf_footer(
- canvas_obj: canvas.Canvas, doc: SimpleDocTemplate, compliance_name: str
-) -> None:
- """
- Add footer with page number and branding to each page of the PDF.
-
- Args:
- canvas_obj (canvas.Canvas): The ReportLab canvas object for drawing.
- doc (SimpleDocTemplate): The document template containing page information.
- """
- canvas_obj.saveState()
- width, height = doc.pagesize
- page_num_text = (
- f"{'PΓ‘gina' if 'ens' in compliance_name.lower() else 'Page'} {doc.page}"
- )
- canvas_obj.setFont("PlusJakartaSans", 9)
- canvas_obj.setFillColorRGB(0.4, 0.4, 0.4)
- canvas_obj.drawString(30, 20, page_num_text)
- powered_text = "Powered by Prowler"
- text_width = canvas_obj.stringWidth(powered_text, "PlusJakartaSans", 9)
- canvas_obj.drawString(width - text_width - 30, 20, powered_text)
- canvas_obj.restoreState()
-
-
-def _create_marco_category_chart(
- requirements_list: list[dict], attributes_by_requirement_id: dict
-) -> io.BytesIO:
- """
- Create a bar chart showing compliance percentage by Marco (Section) and CategorΓa.
-
- Args:
- requirements_list (list[dict]): List of requirement dictionaries with status and findings data.
- attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes.
-
- Returns:
- io.BytesIO: A BytesIO buffer containing the chart image in PNG format.
- """
- # Collect data by Marco and CategorΓa
- marco_categoria_data = defaultdict(lambda: {"passed": 0, "total": 0})
-
- for requirement in requirements_list:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(requirement_id, {})
- requirement_status = requirement["attributes"].get(
- "status", StatusChoices.MANUAL
- )
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if not metadata:
- continue
-
- m = metadata[0]
- marco = _safe_getattr(m, "Marco")
- categoria = _safe_getattr(m, "Categoria")
-
- key = f"{marco} - {categoria}"
- marco_categoria_data[key]["total"] += 1
- if requirement_status == StatusChoices.PASS:
- marco_categoria_data[key]["passed"] += 1
-
- # Calculate percentages
- categories = []
- percentages = []
-
- for category, data in sorted(marco_categoria_data.items()):
- percentage = (data["passed"] / data["total"] * 100) if data["total"] > 0 else 0
- categories.append(category)
- percentages.append(percentage)
-
- if not categories:
- # Return empty chart if no data
- fig, ax = plt.subplots(figsize=(12, 6))
- ax.text(0.5, 0.5, "No data available", ha="center", va="center", fontsize=14)
- ax.set_xlim(0, 1)
- ax.set_ylim(0, 1)
- ax.axis("off")
- buffer = io.BytesIO()
- try:
- plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight")
- buffer.seek(0)
- finally:
- plt.close(fig)
- return buffer
-
- # Create horizontal bar chart
- fig, ax = plt.subplots(figsize=(12, max(8, len(categories) * 0.4)))
-
- # Use helper function for color selection
- colors_list = [_get_chart_color_for_percentage(p) for p in percentages]
-
- y_pos = range(len(categories))
- bars = ax.barh(y_pos, percentages, color=colors_list)
-
- ax.set_yticks(y_pos)
- ax.set_yticklabels(categories, fontsize=16)
- ax.set_xlabel("Porcentaje de Cumplimiento (%)", fontsize=14)
- ax.set_xlim(0, 100)
-
- # Add percentage labels
- for bar, percentage in zip(bars, percentages):
- width = bar.get_width()
- ax.text(
- width + 1,
- bar.get_y() + bar.get_height() / 2.0,
- f"{percentage:.1f}%",
- ha="left",
- va="center",
- fontweight="bold",
- fontsize=10,
- )
-
- ax.grid(True, alpha=0.3, axis="x")
- plt.tight_layout()
-
- buffer = io.BytesIO()
- try:
- # Render canvas and save explicitly from the figure to avoid state bleed
- fig.canvas.draw()
- fig.savefig(buffer, format="png", dpi=300, bbox_inches="tight")
- buffer.seek(0, io.SEEK_END)
- finally:
- plt.close(fig)
-
- return buffer
-
-
-def _create_dimensions_radar_chart(
- requirements_list: list[dict], attributes_by_requirement_id: dict
-) -> io.BytesIO:
- """
- Create a radar/spider chart showing compliance percentage by security dimension.
-
- Args:
- requirements_list (list[dict]): List of requirement dictionaries with status and findings data.
- attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes.
-
- Returns:
- io.BytesIO: A BytesIO buffer containing the chart image in PNG format.
- """
- dimension_data = {key: {"passed": 0, "total": 0} for key in DIMENSION_KEYS}
-
- # Collect data for each dimension
- for requirement in requirements_list:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(requirement_id, {})
- requirement_status = requirement["attributes"].get(
- "status", StatusChoices.MANUAL
- )
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if not metadata:
- continue
-
- m = metadata[0]
- dimensiones_attr = getattr(m, "Dimensiones", None)
- dimensiones = dimensiones_attr or []
- if isinstance(dimensiones, str):
- dimensiones = [dimensiones]
-
- for dimension in dimensiones:
- dimension_lower = dimension.lower()
- if dimension_lower in dimension_data:
- dimension_data[dimension_lower]["total"] += 1
- if requirement_status == StatusChoices.PASS:
- dimension_data[dimension_lower]["passed"] += 1
-
- # Calculate percentages
- percentages = [
- (
- (dimension_data[key]["passed"] / dimension_data[key]["total"] * 100)
- if dimension_data[key]["total"] > 0
- else 100
- ) # No requirements = 100% (no failures)
- for key in DIMENSION_KEYS
- ]
-
- # Create radar chart
- num_dims = len(DIMENSION_NAMES)
- angles = [n / float(num_dims) * 2 * 3.14159 for n in range(num_dims)]
- percentages += percentages[:1]
- angles += angles[:1]
-
- fig, ax = plt.subplots(figsize=(10, 10), subplot_kw=dict(projection="polar"))
-
- ax.plot(angles, percentages, "o-", linewidth=2, color=CHART_COLOR_BLUE)
- ax.fill(angles, percentages, alpha=0.25, color=CHART_COLOR_BLUE)
- ax.set_xticks(angles[:-1])
- ax.set_xticklabels(DIMENSION_NAMES, fontsize=14)
- ax.set_ylim(0, 100)
- ax.set_yticks([20, 40, 60, 80, 100])
- ax.set_yticklabels(["20%", "40%", "60%", "80%", "100%"], fontsize=12)
- ax.grid(True, alpha=0.3)
-
- plt.tight_layout()
-
- buffer = io.BytesIO()
- try:
- fig.canvas.draw()
- fig.savefig(buffer, format="png", dpi=300, bbox_inches="tight")
- buffer.seek(0, io.SEEK_END)
- finally:
- plt.close(fig)
-
- return buffer
-
def generate_threatscore_report(
tenant_id: str,
@@ -1027,911 +29,39 @@ def generate_threatscore_report(
provider_id: str,
only_failed: bool = True,
min_risk_level: int = 4,
- provider_obj=None,
+ provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
) -> None:
"""
Generate a PDF compliance report based on Prowler ThreatScore framework.
- This function creates a comprehensive PDF report containing:
- - Compliance overview and metadata
- - Section-by-section compliance scores with charts
- - Overall ThreatScore calculation
- - Critical failed requirements
- - Detailed findings for each requirement
-
Args:
- tenant_id (str): The tenant ID for Row-Level Security context.
- scan_id (str): ID of the scan executed by Prowler.
- compliance_id (str): ID of the compliance framework (e.g., "prowler_threatscore_aws").
- output_path (str): Output PDF file path (e.g., "/tmp/threatscore_report.pdf").
- provider_id (str): Provider ID for the scan.
- only_failed (bool): If True, only requirements with status "FAIL" will be included
- in the detailed requirements section. Defaults to True.
- min_risk_level (int): Minimum risk level for critical failed requirements. Defaults to 4.
- provider_obj (Provider, optional): Pre-fetched Provider object to avoid duplicate queries.
- If None, the provider will be fetched from the database.
- requirement_statistics (dict, optional): Pre-aggregated requirement statistics to avoid
- duplicate database aggregations. If None, statistics will be aggregated from the database.
- findings_cache (dict, optional): Cache of already loaded findings to avoid duplicate queries.
- If None, findings will be loaded from the database. When provided, reduces database
- queries and transformation overhead when generating multiple reports.
-
- Raises:
- Exception: If any error occurs during PDF generation, it will be logged and re-raised.
+ 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., "prowler_threatscore_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.
+ min_risk_level: Minimum risk level for critical failed requirements.
+ provider_obj: Pre-fetched Provider object to avoid duplicate queries.
+ requirement_statistics: Pre-aggregated requirement statistics.
+ findings_cache: Cache of already loaded findings to avoid duplicate queries.
"""
- logger.info(
- f"Generating the report for the scan {scan_id} with provider {provider_id}"
+ generator = ThreatScoreReportGenerator(FRAMEWORK_REGISTRY["prowler_threatscore"])
+ generator._min_risk_level = min_risk_level
+
+ generator.generate(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ compliance_id=compliance_id,
+ output_path=output_path,
+ provider_id=provider_id,
+ provider_obj=provider_obj,
+ requirement_statistics=requirement_statistics,
+ findings_cache=findings_cache,
+ only_failed=only_failed,
)
- try:
- # Get PDF styles
- pdf_styles = _create_pdf_styles()
- title_style = pdf_styles["title"]
- h1 = pdf_styles["h1"]
- h2 = pdf_styles["h2"]
- h3 = pdf_styles["h3"]
- normal = pdf_styles["normal"]
- normal_center = pdf_styles["normal_center"]
-
- # Get compliance and provider information
- with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
- # Use provided provider_obj or fetch from database
- if provider_obj is None:
- provider_obj = Provider.objects.get(id=provider_id)
-
- prowler_provider = initialize_prowler_provider(provider_obj)
- provider_type = provider_obj.provider
-
- frameworks_bulk = Compliance.get_bulk(provider_type)
- compliance_obj = frameworks_bulk[compliance_id]
- compliance_framework = _safe_getattr(compliance_obj, "Framework")
- compliance_version = _safe_getattr(compliance_obj, "Version")
- compliance_name = _safe_getattr(compliance_obj, "Name")
- compliance_description = _safe_getattr(compliance_obj, "Description", "")
-
- # Aggregate requirement statistics from database (memory-efficient)
- # Use provided requirement_statistics or fetch from database
- if requirement_statistics is None:
- logger.info(f"Aggregating requirement statistics for scan {scan_id}")
- requirement_statistics_by_check_id = (
- _aggregate_requirement_statistics_from_database(tenant_id, scan_id)
- )
- else:
- logger.info(
- f"Reusing pre-aggregated requirement statistics for scan {scan_id}"
- )
- requirement_statistics_by_check_id = requirement_statistics
-
- # Calculate requirements data using aggregated statistics
- attributes_by_requirement_id, requirements_list = (
- _calculate_requirements_data_from_statistics(
- compliance_obj, requirement_statistics_by_check_id
- )
- )
-
- # Initialize PDF document
- doc = SimpleDocTemplate(
- output_path,
- pagesize=letter,
- title=f"Prowler ThreatScore Report - {compliance_framework}",
- author="Prowler",
- subject=f"Compliance Report for {compliance_framework}",
- creator="Prowler Engineering Team",
- keywords=f"compliance,{compliance_framework},security,framework,prowler",
- )
-
- elements = []
-
- # Add logo
- img_path = os.path.join(
- os.path.dirname(__file__), "../assets/img/prowler_logo.png"
- )
- logo = Image(
- img_path,
- width=5 * inch,
- height=1 * inch,
- )
- elements.append(logo)
-
- elements.append(Spacer(1, 0.5 * inch))
- elements.append(Paragraph("Prowler ThreatScore Report", title_style))
- elements.append(Spacer(1, 0.5 * inch))
-
- # Add compliance information table
- provider_alias = provider_obj.alias or "N/A"
- info_data = [
- ["Framework:", compliance_framework],
- ["ID:", compliance_id],
- ["Name:", Paragraph(compliance_name, normal_center)],
- ["Version:", compliance_version],
- ["Provider:", provider_type.upper()],
- ["Account ID:", provider_obj.uid],
- ["Alias:", provider_alias],
- ["Scan ID:", scan_id],
- ["Description:", Paragraph(compliance_description, normal_center)],
- ]
- info_table = Table(info_data, colWidths=[COL_WIDTH_XLARGE, 4 * inch])
- info_table.setStyle(_create_info_table_style())
-
- elements.append(info_table)
- elements.append(PageBreak())
-
- # Add compliance score chart
- elements.append(Paragraph("Compliance Score by Sections", h1))
- elements.append(Spacer(1, 0.2 * inch))
-
- chart_buffer = _create_section_score_chart(
- requirements_list, attributes_by_requirement_id
- )
- chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch)
- elements.append(chart_image)
-
- # Calculate overall ThreatScore using the same formula as the UI
- numerator = 0
- denominator = 0
- has_findings = False
-
- for requirement in requirements_list:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
-
- # Get findings data
- passed_findings = requirement["attributes"].get("passed_findings", 0)
- total_findings = requirement["attributes"].get("total_findings", 0)
-
- # Skip if no findings (avoid division by zero)
- if total_findings == 0:
- continue
-
- has_findings = True
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if metadata and len(metadata) > 0:
- m = metadata[0]
- risk_level = getattr(m, "LevelOfRisk", 0)
- weight = getattr(m, "Weight", 0)
-
- # Calculate using ThreatScore formula from UI
- rate_i = passed_findings / total_findings
- rfac_i = 1 + 0.25 * risk_level
-
- numerator += rate_i * total_findings * weight * rfac_i
- denominator += total_findings * weight * rfac_i
-
- # Calculate ThreatScore (percentualScore)
- # If no findings exist, consider it 100% (PASS)
- if not has_findings:
- overall_compliance = 100
- elif denominator > 0:
- overall_compliance = (numerator / denominator) * 100
- else:
- overall_compliance = 0
-
- elements.append(Spacer(1, 0.3 * inch))
-
- summary_data = [
- ["ThreatScore:", f"{overall_compliance:.2f}%"],
- ]
-
- compliance_color = _get_color_for_compliance(overall_compliance)
-
- summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch])
- summary_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)),
- ("TEXTCOLOR", (0, 0), (0, 0), colors.white),
- ("FONTNAME", (0, 0), (0, 0), "FiraCode"),
- ("FONTSIZE", (0, 0), (0, 0), 12),
- ("BACKGROUND", (1, 0), (1, 0), compliance_color),
- ("TEXTCOLOR", (1, 0), (1, 0), colors.white),
- ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
- ("FONTSIZE", (1, 0), (1, 0), 16),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)),
- ("LEFTPADDING", (0, 0), (-1, -1), 12),
- ("RIGHTPADDING", (0, 0), (-1, -1), 12),
- ("TOPPADDING", (0, 0), (-1, -1), 10),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
- ]
- )
- )
-
- elements.append(summary_table)
- elements.append(PageBreak())
-
- # Add requirements index
- elements.append(Paragraph("Requirements Index", h1))
-
- sections = {}
- for (
- requirement_id,
- requirement_attributes,
- ) in attributes_by_requirement_id.items():
- meta = requirement_attributes["attributes"]["req_attributes"][0]
- section = getattr(meta, "Section", "N/A")
- subsection = getattr(meta, "SubSection", "N/A")
- title = getattr(meta, "Title", "N/A")
-
- if section not in sections:
- sections[section] = {}
- if subsection not in sections[section]:
- sections[section][subsection] = []
-
- sections[section][subsection].append({"id": requirement_id, "title": title})
-
- section_num = 1
- for section_name, subsections in sections.items():
- elements.append(Paragraph(f"{section_num}. {section_name}", h2))
-
- subsection_num = 1
- for subsection_name, requirements in subsections.items():
- elements.append(Paragraph(f"{subsection_name}", h3))
-
- req_num = 1
- for req in requirements:
- elements.append(Paragraph(f"{req['id']} - {req['title']}", normal))
- req_num += 1
-
- subsection_num += 1
-
- section_num += 1
- elements.append(Spacer(1, 0.1 * inch))
-
- elements.append(PageBreak())
-
- # Add critical failed requirements section
- elements.append(Paragraph("Top Requirements by Level of Risk", h1))
- elements.append(Spacer(1, 0.1 * inch))
- elements.append(
- Paragraph(
- f"Critical Failed Requirements (Risk Level β₯ {min_risk_level})", h2
- )
- )
- elements.append(Spacer(1, 0.2 * inch))
-
- critical_failed_requirements = []
- for requirement in requirements_list:
- requirement_status = requirement["attributes"]["status"]
- if requirement_status == StatusChoices.FAIL:
- requirement_id = requirement["id"]
- metadata = (
- attributes_by_requirement_id.get(requirement_id, {})
- .get("attributes", {})
- .get("req_attributes", [{}])[0]
- )
- if metadata:
- risk_level = getattr(metadata, "LevelOfRisk", 0)
- weight = getattr(metadata, "Weight", 0)
-
- if risk_level >= min_risk_level:
- critical_failed_requirements.append(
- {
- "requirement": requirement,
- "attributes": attributes_by_requirement_id[
- requirement_id
- ],
- "risk_level": risk_level,
- "weight": weight,
- "metadata": metadata,
- }
- )
-
- critical_failed_requirements.sort(
- key=lambda x: (x["risk_level"], x["weight"]), reverse=True
- )
-
- if not critical_failed_requirements:
- elements.append(
- Paragraph(
- "β
No critical failed requirements found. Great job!", normal
- )
- )
- else:
- elements.append(
- Paragraph(
- f"Found {len(critical_failed_requirements)} critical failed requirements that require immediate attention:",
- normal,
- )
- )
- elements.append(Spacer(1, 0.5 * inch))
-
- table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]]
-
- for idx, critical_failed_requirement in enumerate(
- critical_failed_requirements
- ):
- requirement_id = critical_failed_requirement["requirement"]["id"]
- risk_level = critical_failed_requirement["risk_level"]
- weight = critical_failed_requirement["weight"]
- title = getattr(critical_failed_requirement["metadata"], "Title", "N/A")
- section = getattr(
- critical_failed_requirement["metadata"], "Section", "N/A"
- )
-
- if len(title) > 50:
- title = title[:47] + "..."
-
- table_data.append(
- [str(risk_level), str(weight), requirement_id, title, section]
- )
-
- critical_table = Table(
- table_data,
- colWidths=[0.7 * inch, 0.9 * inch, 1.3 * inch, 3.1 * inch, 1.5 * inch],
- )
-
- critical_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.8, 0.2, 0.2)),
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- ("FONTSIZE", (0, 0), (-1, 0), 10),
- ("BACKGROUND", (0, 1), (0, -1), colors.Color(0.8, 0.2, 0.2)),
- ("TEXTCOLOR", (0, 1), (0, -1), colors.white),
- ("FONTNAME", (0, 1), (0, -1), "FiraCode"),
- ("ALIGN", (0, 1), (0, -1), "CENTER"),
- ("FONTSIZE", (0, 1), (0, -1), 12),
- ("ALIGN", (1, 1), (1, -1), "CENTER"),
- ("FONTNAME", (1, 1), (1, -1), "FiraCode"),
- ("FONTNAME", (2, 1), (2, -1), "FiraCode"),
- ("FONTSIZE", (2, 1), (2, -1), 9),
- ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"),
- ("FONTSIZE", (3, 1), (-1, -1), 8),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)),
- ("LEFTPADDING", (0, 0), (-1, -1), 6),
- ("RIGHTPADDING", (0, 0), (-1, -1), 6),
- ("TOPPADDING", (0, 0), (-1, -1), 8),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
- (
- "BACKGROUND",
- (1, 1),
- (-1, -1),
- colors.Color(0.98, 0.98, 0.98),
- ),
- ]
- )
- )
-
- for idx, critical_failed_requirement in enumerate(
- critical_failed_requirements
- ):
- row_idx = idx + 1
- weight = critical_failed_requirement["weight"]
-
- if weight >= 150:
- weight_color = colors.Color(0.8, 0.2, 0.2)
- elif weight >= 100:
- weight_color = colors.Color(0.9, 0.6, 0.2)
- else:
- weight_color = colors.Color(0.9, 0.9, 0.2)
-
- critical_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color),
- ("TEXTCOLOR", (1, row_idx), (1, row_idx), colors.white),
- ]
- )
- )
-
- elements.append(critical_table)
- elements.append(Spacer(1, 0.2 * inch))
-
- # Get styles for warning
- styles = getSampleStyleSheet()
- warning_text = """
- IMMEDIATE ACTION REQUIRED:
- These requirements have the highest risk levels and have failed compliance checks.
- Please prioritize addressing these issues to improve your security posture.
- """
-
- warning_style = ParagraphStyle(
- "Warning",
- parent=styles["Normal"],
- fontSize=11,
- textColor=colors.Color(0.8, 0.2, 0.2),
- spaceBefore=10,
- spaceAfter=10,
- leftIndent=20,
- rightIndent=20,
- fontName="PlusJakartaSans",
- backColor=colors.Color(1.0, 0.95, 0.95),
- borderWidth=2,
- borderColor=colors.Color(0.8, 0.2, 0.2),
- borderPadding=10,
- )
-
- elements.append(Paragraph(warning_text, warning_style))
-
- elements.append(PageBreak())
-
- # Add detailed requirements section
- def get_weight_for_requirement(requirement_dict):
- requirement_id = requirement_dict["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if metadata:
- return getattr(metadata[0], "Weight", 0)
- return 0
-
- sorted_requirements = sorted(
- requirements_list, key=get_weight_for_requirement, reverse=True
- )
-
- if only_failed:
- sorted_requirements = [
- requirement
- for requirement in sorted_requirements
- if requirement["attributes"]["status"] == StatusChoices.FAIL
- ]
-
- # Collect all check IDs for requirements that will be displayed
- # This allows us to load only the findings we actually need (memory optimization)
- check_ids_to_load = []
- for requirement in sorted_requirements:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
- check_ids = requirement_attributes.get("attributes", {}).get("checks", [])
- check_ids_to_load.extend(check_ids)
-
- # Load findings on-demand only for the checks that will be displayed
- logger.info(
- f"Loading findings on-demand for {len(sorted_requirements)} requirements"
- )
- findings_by_check_id = _load_findings_for_requirement_checks(
- tenant_id, scan_id, check_ids_to_load, prowler_provider, findings_cache
- )
-
- for requirement in sorted_requirements:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
- requirement_description = requirement["attributes"]["description"]
- requirement_status = requirement["attributes"]["status"]
-
- elements.append(
- Paragraph(
- f"{requirement_id}: {requirement_attributes.get('description', requirement_description)}",
- h1,
- )
- )
-
- status_component = _create_status_component(requirement_status)
- elements.append(status_component)
- elements.append(Spacer(1, 0.1 * inch))
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if metadata and len(metadata) > 0:
- m = metadata[0]
- elements.append(Paragraph("Title: ", h3))
- elements.append(Paragraph(f"{getattr(m, 'Title', 'N/A')}", normal))
- elements.append(Paragraph("Section: ", h3))
- elements.append(Paragraph(f"{getattr(m, 'Section', 'N/A')}", normal))
- elements.append(Paragraph("SubSection: ", h3))
- elements.append(Paragraph(f"{getattr(m, 'SubSection', 'N/A')}", normal))
- elements.append(Paragraph("Description: ", h3))
- elements.append(
- Paragraph(f"{getattr(m, 'AttributeDescription', 'N/A')}", normal)
- )
- elements.append(Paragraph("Additional Information: ", h3))
- elements.append(
- Paragraph(f"{getattr(m, 'AdditionalInformation', 'N/A')}", normal)
- )
- elements.append(Spacer(1, 0.1 * inch))
-
- risk_level = getattr(m, "LevelOfRisk", 0)
- weight = getattr(m, "Weight", 0)
-
- if requirement_status == StatusChoices.PASS:
- score = risk_level * weight
- else:
- score = 0
-
- risk_component = _create_risk_component(risk_level, weight, score)
- elements.append(risk_component)
- elements.append(Spacer(1, 0.1 * inch))
-
- # Get findings for this requirement's checks (loaded on-demand earlier)
- requirement_check_ids = requirement_attributes.get("attributes", {}).get(
- "checks", []
- )
- for check_id in requirement_check_ids:
- elements.append(Paragraph(f"Check: {check_id}", h2))
- elements.append(Spacer(1, 0.1 * inch))
-
- # Get findings for this check (already loaded on-demand)
- check_findings = findings_by_check_id.get(check_id, [])
-
- if not check_findings:
- elements.append(
- Paragraph("- No information for this finding currently", normal)
- )
- else:
- findings_table_data = [
- [
- "Finding",
- "Resource name",
- "Severity",
- "Status",
- "Region",
- ]
- ]
- for finding_output in check_findings:
- check_metadata = getattr(finding_output, "metadata", {})
- finding_title = getattr(
- check_metadata,
- "CheckTitle",
- getattr(finding_output, "check_id", ""),
- )
- resource_name = getattr(finding_output, "resource_name", "")
- if not resource_name:
- resource_name = getattr(finding_output, "resource_uid", "")
- severity = getattr(check_metadata, "Severity", "").capitalize()
- finding_status = getattr(finding_output, "status", "").upper()
- region = getattr(finding_output, "region", "global")
-
- findings_table_data.append(
- [
- Paragraph(finding_title, normal_center),
- Paragraph(resource_name, normal_center),
- Paragraph(severity, normal_center),
- Paragraph(finding_status, normal_center),
- Paragraph(region, normal_center),
- ]
- )
- findings_table = Table(
- findings_table_data,
- colWidths=[
- 2.5 * inch,
- 3 * inch,
- 0.9 * inch,
- 0.9 * inch,
- 0.9 * inch,
- ],
- )
- findings_table.setStyle(
- TableStyle(
- [
- (
- "BACKGROUND",
- (0, 0),
- (-1, 0),
- colors.Color(0.2, 0.4, 0.6),
- ),
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- ("ALIGN", (0, 0), (0, 0), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 9),
- (
- "GRID",
- (0, 0),
- (-1, -1),
- 0.1,
- colors.Color(0.7, 0.8, 0.9),
- ),
- ("LEFTPADDING", (0, 0), (0, 0), 0),
- ("RIGHTPADDING", (0, 0), (0, 0), 0),
- ("TOPPADDING", (0, 0), (-1, -1), 4),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
- ]
- )
- )
- elements.append(findings_table)
- elements.append(Spacer(1, 0.1 * inch))
-
- elements.append(PageBreak())
-
- # Build the PDF
- doc.build(
- elements,
- onFirstPage=partial(_add_pdf_footer, compliance_name=compliance_name),
- onLaterPages=partial(_add_pdf_footer, compliance_name=compliance_name),
- )
- except Exception as e:
- tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown"
- logger.info(f"Error building the document, line {tb_lineno} -- {e}")
- raise e
-
-
-def _create_nis2_section_chart(
- requirements_list: list[dict], attributes_by_requirement_id: dict
-) -> io.BytesIO:
- """
- Create a horizontal bar chart showing compliance percentage by NIS2 section.
-
- Args:
- requirements_list (list[dict]): List of requirement dictionaries with status and findings data.
- attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes.
-
- Returns:
- io.BytesIO: A BytesIO buffer containing the chart image in PNG format.
- """
- # Initialize sections data
- sections_data = defaultdict(lambda: {"passed": 0, "total": 0})
-
- # Collect data from requirements
- for requirement in requirements_list:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(requirement_id, {})
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if not metadata:
- continue
-
- m = metadata[0]
- section_full = _safe_getattr(m, "Section", "")
-
- # Extract section number (e.g., "1" from "1 POLICY ON...")
- section_number = section_full.split()[0] if section_full else "Unknown"
-
- # Get findings data
- passed_findings = requirement["attributes"].get("passed_findings", 0)
- total_findings = requirement["attributes"].get("total_findings", 0)
-
- if total_findings > 0:
- sections_data[section_number]["passed"] += passed_findings
- sections_data[section_number]["total"] += total_findings
-
- # Calculate percentages and prepare data for chart
- section_names = []
- compliance_percentages = []
-
- # Get section titles for display
- section_titles = {
- "1": "1. Policy on Security",
- "2": "2. Risk Management",
- "3": "3. Incident Handling",
- "4": "4. Business Continuity",
- "5": "5. Supply Chain",
- "6": "6. Acquisition & Dev",
- "7": "7. Effectiveness",
- "9": "9. Cryptography",
- "11": "11. Access Control",
- "12": "12. Asset Management",
- }
-
- # Sort by section number
- for section_num in sorted(
- sections_data.keys(), key=lambda x: int(x) if x.isdigit() else 999
- ):
- data = sections_data[section_num]
- if data["total"] > 0:
- compliance_percentage = (data["passed"] / data["total"]) * 100
- else:
- compliance_percentage = 100 # No findings = 100% (PASS)
-
- section_title = section_titles.get(section_num, f"{section_num}. Unknown")
- section_names.append(section_title)
- compliance_percentages.append(compliance_percentage)
-
- # Generate horizontal bar chart
- fig, ax = plt.subplots(figsize=(10, 8))
-
- # Use color helper for compliance percentage
- colors_list = [_get_chart_color_for_percentage(p) for p in compliance_percentages]
-
- bars = ax.barh(section_names, compliance_percentages, color=colors_list)
-
- ax.set_xlabel("Compliance (%)", fontsize=12)
- ax.set_xlim(0, 100)
-
- # Add percentage labels
- for bar, percentage in zip(bars, compliance_percentages):
- width = bar.get_width()
- ax.text(
- width + 1,
- bar.get_y() + bar.get_height() / 2.0,
- f"{percentage:.1f}%",
- ha="left",
- va="center",
- fontweight="bold",
- )
-
- ax.grid(True, alpha=0.3, axis="x")
- plt.tight_layout()
-
- buffer = io.BytesIO()
- try:
- fig.canvas.draw()
- fig.savefig(buffer, format="png", dpi=300, bbox_inches="tight")
- buffer.seek(0, io.SEEK_END)
- finally:
- plt.close(fig)
-
- return buffer
-
-
-def _create_nis2_subsection_table(
- requirements_list: list[dict], attributes_by_requirement_id: dict
-) -> Table:
- """
- Create a table showing compliance by subsection.
-
- Args:
- requirements_list (list[dict]): List of requirement dictionaries.
- attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes.
-
- Returns:
- Table: A ReportLab table showing subsection breakdown.
- """
- # Collect data by subsection
- subsections_data = defaultdict(lambda: {"passed": 0, "failed": 0, "manual": 0})
-
- for requirement in requirements_list:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(requirement_id, {})
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if not metadata:
- continue
-
- m = metadata[0]
- subsection = _safe_getattr(m, "SubSection", "Unknown")
- status = requirement["attributes"].get("status", StatusChoices.MANUAL)
-
- if status == StatusChoices.PASS:
- subsections_data[subsection]["passed"] += 1
- elif status == StatusChoices.FAIL:
- subsections_data[subsection]["failed"] += 1
- else:
- subsections_data[subsection]["manual"] += 1
-
- # Create table data
- table_data = [["SubSection", "Total", "Pass", "Fail", "Manual", "Compliance %"]]
-
- for subsection in sorted(subsections_data.keys()):
- data = subsections_data[subsection]
- total = data["passed"] + data["failed"] + data["manual"]
- compliance = (
- (data["passed"] / (data["passed"] + data["failed"]) * 100)
- if (data["passed"] + data["failed"]) > 0
- else 100
- )
-
- if len(subsection) > 100:
- subsection = subsection[:80] + "..."
-
- table_data.append(
- [
- subsection, # No truncate - let it wrap naturally
- str(total),
- str(data["passed"]),
- str(data["failed"]),
- str(data["manual"]),
- f"{compliance:.1f}%",
- ]
- )
-
- # Create table with wider SubSection column
- table = Table(
- table_data,
- colWidths=[
- 4.5 * inch,
- 0.6 * inch,
- 0.6 * inch,
- 0.6 * inch,
- 0.7 * inch,
- 1 * inch,
- ],
- )
- table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY),
- ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("ALIGN", (0, 1), (0, -1), "LEFT"),
- ("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"),
- ("FONTSIZE", (0, 0), (-1, 0), 10),
- ("FONTSIZE", (0, 1), (-1, -1), 9),
- ("BOTTOMPADDING", (0, 0), (-1, 0), 8),
- ("TOPPADDING", (0, 0), (-1, 0), 8),
- ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY),
- ("ROWBACKGROUNDS", (0, 1), (-1, -1), [COLOR_WHITE, COLOR_NIS2_BG_BLUE]),
- ]
- )
- )
-
- return table
-
-
-def _create_nis2_requirements_index(
- requirements_list: list[dict], attributes_by_requirement_id: dict, h2, h3, normal
-) -> list:
- """
- Create a hierarchical requirements index organized by Section and SubSection.
-
- Args:
- requirements_list (list[dict]): List of requirement dictionaries.
- attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes.
- h2, h3, normal: Paragraph styles.
-
- Returns:
- list: List of ReportLab elements for the index.
- """
- elements = []
-
- # Organize requirements by section and subsection
- sections_hierarchy = defaultdict(lambda: defaultdict(list))
-
- for requirement in requirements_list:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(requirement_id, {})
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if not metadata:
- continue
-
- m = metadata[0]
- section = _safe_getattr(m, "Section", "Unknown")
- subsection = _safe_getattr(m, "SubSection", "Unknown")
- status = requirement["attributes"].get("status", StatusChoices.MANUAL)
-
- # Status indicator
- if status == StatusChoices.PASS:
- status_indicator = "β"
- elif status == StatusChoices.FAIL:
- status_indicator = "β"
- else:
- status_indicator = "β"
-
- description = requirement["attributes"].get(
- "description", "No description available"
- )
- sections_hierarchy[section][subsection].append(
- {
- "id": requirement_id,
- "description": (
- description[:100] + "..." if len(description) > 100 else description
- ),
- "status_indicator": status_indicator,
- }
- )
-
- # Build the index
- for section in sorted(sections_hierarchy.keys()):
- # Section header
- elements.append(Paragraph(section, h2))
-
- subsections = sections_hierarchy[section]
- for subsection in sorted(subsections.keys()):
- # Subsection header
- elements.append(Paragraph(f" {subsection}", h3))
-
- # Requirements
- for req in subsections[subsection]:
- req_text = (
- f" {req['status_indicator']} {req['id']} - {req['description']}"
- )
- elements.append(Paragraph(req_text, normal))
-
- elements.append(Spacer(1, 0.1 * inch))
-
- return elements
def generate_ens_report(
@@ -1941,952 +71,37 @@ def generate_ens_report(
output_path: str,
provider_id: str,
include_manual: bool = True,
- provider_obj=None,
+ provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
) -> None:
"""
Generate a PDF compliance report for ENS RD2022 framework.
- This function creates a comprehensive PDF report containing:
- - Compliance overview and metadata
- - Executive summary with overall compliance score
- - Marco/CategorΓa analysis with charts
- - Security dimensions radar chart
- - Requirement type distribution
- - Execution mode distribution
- - Critical failed requirements (nivel alto)
- - Requirements index
- - Detailed findings for failed and manual requirements
-
Args:
- tenant_id (str): The tenant ID for Row-Level Security context.
- scan_id (str): ID of the scan executed by Prowler.
- compliance_id (str): ID of the compliance framework (e.g., "ens_rd2022_aws").
- output_path (str): Output PDF file path (e.g., "/tmp/ens_report.pdf").
- provider_id (str): Provider ID for the scan.
- include_manual (bool): If True, include requirements with manual execution mode
- in the detailed requirements section. Defaults to True.
- provider_obj (Provider, optional): Pre-fetched Provider object to avoid duplicate queries.
- If None, the provider will be fetched from the database.
- requirement_statistics (dict, optional): Pre-aggregated requirement statistics to avoid
- duplicate database aggregations. If None, statistics will be aggregated from the database.
- findings_cache (dict, optional): Cache of already loaded findings to avoid duplicate queries.
- If None, findings will be loaded from the database. When provided, reduces database
- queries and transformation overhead when generating multiple reports.
-
- Raises:
- Exception: If any error occurs during PDF generation, it will be logged and re-raised.
+ 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., "ens_rd2022_aws").
+ output_path: Output PDF file path.
+ provider_id: Provider ID for the scan.
+ include_manual: If True, include manual requirements in detailed section.
+ provider_obj: Pre-fetched Provider object to avoid duplicate queries.
+ requirement_statistics: Pre-aggregated requirement statistics.
+ findings_cache: Cache of already loaded findings to avoid duplicate queries.
"""
- logger.info(f"Generating ENS report for scan {scan_id} with provider {provider_id}")
- try:
- # Get PDF styles
- pdf_styles = _create_pdf_styles()
- title_style = pdf_styles["title"]
- h1 = pdf_styles["h1"]
- h2 = pdf_styles["h2"]
- h3 = pdf_styles["h3"]
- normal = pdf_styles["normal"]
- normal_center = pdf_styles["normal_center"]
-
- # Get compliance and provider information
- with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
- # Use provided provider_obj or fetch from database
- if provider_obj is None:
- provider_obj = Provider.objects.get(id=provider_id)
-
- prowler_provider = initialize_prowler_provider(provider_obj)
- provider_type = provider_obj.provider
-
- frameworks_bulk = Compliance.get_bulk(provider_type)
- compliance_obj = frameworks_bulk[compliance_id]
- compliance_framework = _safe_getattr(compliance_obj, "Framework")
- compliance_version = _safe_getattr(compliance_obj, "Version")
- compliance_name = _safe_getattr(compliance_obj, "Name")
- compliance_description = _safe_getattr(compliance_obj, "Description", "")
-
- # Aggregate requirement statistics from database (memory-efficient)
- # Use provided requirement_statistics or fetch from database
- if requirement_statistics is None:
- logger.info(f"Aggregating requirement statistics for scan {scan_id}")
- requirement_statistics_by_check_id = (
- _aggregate_requirement_statistics_from_database(tenant_id, scan_id)
- )
- else:
- logger.info(
- f"Reusing pre-aggregated requirement statistics for scan {scan_id}"
- )
- requirement_statistics_by_check_id = requirement_statistics
-
- # Calculate requirements data using aggregated statistics
- attributes_by_requirement_id, requirements_list = (
- _calculate_requirements_data_from_statistics(
- compliance_obj, requirement_statistics_by_check_id
- )
- )
-
- # Count manual requirements before filtering
- manual_requirements_count = sum(
- 1
- for req in requirements_list
- if req["attributes"]["status"] == StatusChoices.MANUAL
- )
- total_requirements_count = len(requirements_list)
-
- # Filter out manual requirements for the report
- requirements_list = [
- req
- for req in requirements_list
- if req["attributes"]["status"] != StatusChoices.MANUAL
- ]
-
- logger.info(
- f"Filtered {manual_requirements_count} manual requirements out of {total_requirements_count} total requirements"
- )
-
- # Initialize PDF document
- doc = SimpleDocTemplate(
- output_path,
- pagesize=letter,
- title="Informe de Cumplimiento ENS - Prowler",
- author="Prowler",
- subject=f"Informe de Cumplimiento para {compliance_framework}",
- creator="Prowler Engineering Team",
- keywords=f"compliance,{compliance_framework},security,ens,prowler",
- )
-
- elements = []
-
- # SECTION 1: PORTADA (Cover Page)
- # Create logos side by side
- prowler_logo_path = os.path.join(
- os.path.dirname(__file__), "../assets/img/prowler_logo.png"
- )
- ens_logo_path = os.path.join(
- os.path.dirname(__file__), "../assets/img/ens_logo.png"
- )
-
- prowler_logo = Image(
- prowler_logo_path,
- width=3.5 * inch,
- height=0.7 * inch,
- )
- ens_logo = Image(
- ens_logo_path,
- width=1.5 * inch,
- height=2 * inch,
- )
-
- # Create table with both logos
- logos_table = Table(
- [[prowler_logo, ens_logo]], colWidths=[4 * inch, 2.5 * inch]
- )
- logos_table.setStyle(
- TableStyle(
- [
- ("ALIGN", (0, 0), (0, 0), "LEFT"),
- ("ALIGN", (1, 0), (1, 0), "RIGHT"),
- ("VALIGN", (0, 0), (0, 0), "MIDDLE"), # Prowler logo middle
- ("VALIGN", (1, 0), (1, 0), "TOP"), # ENS logo top
- ]
- )
- )
- elements.append(logos_table)
- elements.append(Spacer(1, 0.3 * inch))
- elements.append(
- Paragraph("Informe de Cumplimiento ENS RD 311/2022", title_style)
- )
- elements.append(Spacer(1, 0.5 * inch))
-
- # Add compliance information table
- provider_alias = provider_obj.alias or "N/A"
- info_data = [
- ["Framework:", compliance_framework],
- ["ID:", compliance_id],
- ["Nombre:", Paragraph(compliance_name, normal_center)],
- ["VersiΓ³n:", compliance_version],
- ["Proveedor:", provider_type.upper()],
- ["Account ID:", provider_obj.uid],
- ["Alias:", provider_alias],
- ["Scan ID:", scan_id],
- ["DescripciΓ³n:", Paragraph(compliance_description, normal_center)],
- ]
- info_table = Table(info_data, colWidths=[2 * inch, 4 * inch])
- info_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, -1), colors.Color(0.2, 0.4, 0.6)),
- ("TEXTCOLOR", (0, 0), (0, -1), colors.white),
- ("FONTNAME", (0, 0), (0, -1), "FiraCode"),
- ("BACKGROUND", (1, 0), (1, -1), colors.Color(0.95, 0.97, 1.0)),
- ("TEXTCOLOR", (1, 0), (1, -1), colors.Color(0.2, 0.2, 0.2)),
- ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
- ("ALIGN", (0, 0), (-1, -1), "LEFT"),
- ("VALIGN", (0, 0), (-1, -1), "TOP"),
- ("FONTSIZE", (0, 0), (-1, -1), 11),
- ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)),
- ("LEFTPADDING", (0, 0), (-1, -1), 10),
- ("RIGHTPADDING", (0, 0), (-1, -1), 10),
- ("TOPPADDING", (0, 0), (-1, -1), 8),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
- ]
- )
- )
- elements.append(info_table)
- elements.append(Spacer(1, 0.5 * inch))
-
- # Add warning about excluded manual requirements
- warning_text = (
- f"AVISO: Este informe no incluye los requisitos de ejecuciΓ³n manual. "
- f"El compliance {compliance_id} contiene un total de "
- f"{manual_requirements_count} requisitos manuales que no han sido evaluados "
- f"automΓ‘ticamente y por tanto no estΓ‘n reflejados en las estadΓsticas de este reporte. "
- f"El anΓ‘lisis se basa ΓΊnicamente en los {len(requirements_list)} requisitos automatizados."
- )
- warning_paragraph = Paragraph(warning_text, normal)
- warning_table = Table([[warning_paragraph]], colWidths=[6 * inch])
- warning_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), colors.Color(1.0, 0.95, 0.7)),
- ("TEXTCOLOR", (0, 0), (0, 0), colors.Color(0.4, 0.3, 0.0)),
- ("ALIGN", (0, 0), (0, 0), "LEFT"),
- ("VALIGN", (0, 0), (0, 0), "MIDDLE"),
- ("BOX", (0, 0), (-1, -1), 2, colors.Color(0.9, 0.7, 0.0)),
- ("LEFTPADDING", (0, 0), (-1, -1), 15),
- ("RIGHTPADDING", (0, 0), (-1, -1), 15),
- ("TOPPADDING", (0, 0), (-1, -1), 12),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 12),
- ]
- )
- )
- elements.append(warning_table)
- elements.append(Spacer(1, 0.5 * inch))
-
- # Add legend explaining ENS values
- elements.append(Paragraph("Leyenda de Valores ENS", h2))
- elements.append(Spacer(1, 0.2 * inch))
-
- legend_text = """
- Nivel (Criticidad del requisito):
- β’ Alto: Requisitos crΓticos que deben cumplirse prioritariamente
- β’ Medio: Requisitos importantes con impacto moderado
- β’ Bajo: Requisitos complementarios de menor criticidad
- β’ Opcional: Recomendaciones adicionales no obligatorias
-
- Tipo (ClasificaciΓ³n del requisito):
- β’ Requisito: ObligaciΓ³n establecida por el ENS
- β’ Refuerzo: Medida adicional que refuerza un requisito
- β’ RecomendaciΓ³n: Buena prΓ‘ctica sugerida
- β’ Medida: AcciΓ³n concreta de implementaciΓ³n
-
- Modo de EjecuciΓ³n:
- β’ AutomΓ‘tico: El requisito puede verificarse automΓ‘ticamente mediante escaneo
- β’ Manual: Requiere verificaciΓ³n manual por parte de un auditor
-
- Dimensiones de Seguridad:
- β’ C (Confidencialidad): ProtecciΓ³n contra accesos no autorizados a la informaciΓ³n
- β’ I (Integridad): GarantΓa de exactitud y completitud de la informaciΓ³n
- β’ T (Trazabilidad): Capacidad de rastrear acciones y eventos
- β’ A (Autenticidad): VerificaciΓ³n de identidad de usuarios y sistemas
- β’ D (Disponibilidad): Acceso a la informaciΓ³n cuando se necesita
-
- Estados de Cumplimiento:
- β’ CUMPLE (PASS): El requisito se cumple satisfactoriamente
- β’ NO CUMPLE (FAIL): El requisito no se cumple y requiere correcciΓ³n
- β’ MANUAL: Requiere revisiΓ³n manual para determinar cumplimiento
- """
- legend_paragraph = Paragraph(legend_text, normal)
- legend_table = Table([[legend_paragraph]], colWidths=[6.5 * inch])
- legend_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.95, 0.97, 1.0)),
- ("TEXTCOLOR", (0, 0), (0, 0), colors.Color(0.2, 0.2, 0.2)),
- ("ALIGN", (0, 0), (0, 0), "LEFT"),
- ("VALIGN", (0, 0), (0, 0), "TOP"),
- ("BOX", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.8)),
- ("LEFTPADDING", (0, 0), (-1, -1), 15),
- ("RIGHTPADDING", (0, 0), (-1, -1), 15),
- ("TOPPADDING", (0, 0), (-1, -1), 12),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 12),
- ]
- )
- )
- elements.append(legend_table)
- elements.append(PageBreak())
-
- # SECTION 2: RESUMEN EJECUTIVO (Executive Summary)
- elements.append(Paragraph("Resumen Ejecutivo", h1))
- elements.append(Spacer(1, 0.2 * inch))
-
- # Calculate overall compliance (simple PASS/TOTAL)
- total_requirements = len(requirements_list)
- passed_requirements = sum(
- 1
- for req in requirements_list
- if req["attributes"]["status"] == StatusChoices.PASS
- )
- failed_requirements = sum(
- 1
- for req in requirements_list
- if req["attributes"]["status"] == StatusChoices.FAIL
- )
-
- overall_compliance = (
- (passed_requirements / total_requirements * 100)
- if total_requirements > 0
- else 0
- )
-
- if overall_compliance >= 80:
- compliance_color = colors.Color(0.2, 0.8, 0.2)
- elif overall_compliance >= 60:
- compliance_color = colors.Color(0.8, 0.8, 0.2)
- else:
- compliance_color = colors.Color(0.8, 0.2, 0.2)
-
- summary_data = [
- ["Nivel de Cumplimiento Global:", f"{overall_compliance:.2f}%"],
- ]
-
- summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch])
- summary_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)),
- ("TEXTCOLOR", (0, 0), (0, 0), colors.white),
- ("FONTNAME", (0, 0), (0, 0), "FiraCode"),
- ("FONTSIZE", (0, 0), (0, 0), 12),
- ("BACKGROUND", (1, 0), (1, 0), compliance_color),
- ("TEXTCOLOR", (1, 0), (1, 0), colors.white),
- ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
- ("FONTSIZE", (1, 0), (1, 0), 16),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)),
- ("LEFTPADDING", (0, 0), (-1, -1), 12),
- ("RIGHTPADDING", (0, 0), (-1, -1), 12),
- ("TOPPADDING", (0, 0), (-1, -1), 10),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
- ]
- )
- )
- elements.append(summary_table)
- elements.append(Spacer(1, 0.3 * inch))
-
- # Summary counts table
- counts_data = [
- ["Estado", "Cantidad", "Porcentaje"],
- [
- "CUMPLE",
- str(passed_requirements),
- (
- f"{(passed_requirements / total_requirements * 100):.1f}%"
- if total_requirements > 0
- else "0.0%"
- ),
- ],
- [
- "NO CUMPLE",
- str(failed_requirements),
- (
- f"{(failed_requirements / total_requirements * 100):.1f}%"
- if total_requirements > 0
- else "0.0%"
- ),
- ],
- ["TOTAL", str(total_requirements), "100%"],
- ]
-
- counts_table = Table(counts_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch])
- counts_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.6)),
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- ("BACKGROUND", (0, 1), (0, 1), colors.Color(0.2, 0.8, 0.2)),
- ("TEXTCOLOR", (0, 1), (0, 1), colors.white),
- ("BACKGROUND", (0, 2), (0, 2), colors.Color(0.8, 0.2, 0.2)),
- ("TEXTCOLOR", (0, 2), (0, 2), colors.white),
- ("BACKGROUND", (0, 3), (0, 3), colors.Color(0.4, 0.4, 0.4)),
- ("TEXTCOLOR", (0, 3), (0, 3), colors.white),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 10),
- ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)),
- ("LEFTPADDING", (0, 0), (-1, -1), 8),
- ("RIGHTPADDING", (0, 0), (-1, -1), 8),
- ("TOPPADDING", (0, 0), (-1, -1), 6),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
- ]
- )
- )
- elements.append(counts_table)
- elements.append(Spacer(1, 0.3 * inch))
-
- # Summary by Nivel
- nivel_data = defaultdict(lambda: {"passed": 0, "total": 0})
- for requirement in requirements_list:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
- requirement_status = requirement["attributes"]["status"]
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if not metadata:
- continue
-
- m = metadata[0]
- nivel = _safe_getattr(m, "Nivel")
- nivel_data[nivel]["total"] += 1
- if requirement_status == StatusChoices.PASS:
- nivel_data[nivel]["passed"] += 1
-
- elements.append(Paragraph("Cumplimiento por Nivel", h2))
- nivel_table_data = [["Nivel", "Cumplidos", "Total", "Porcentaje"]]
- for nivel in ENS_NIVEL_ORDER:
- if nivel in nivel_data:
- data = nivel_data[nivel]
- percentage = (
- (data["passed"] / data["total"] * 100) if data["total"] > 0 else 0
- )
- nivel_table_data.append(
- [
- nivel.capitalize(),
- str(data["passed"]),
- str(data["total"]),
- f"{percentage:.1f}%",
- ]
- )
-
- nivel_table = Table(
- nivel_table_data, colWidths=[1.5 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch]
- )
- nivel_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.6)),
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 10),
- ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)),
- ("LEFTPADDING", (0, 0), (-1, -1), 8),
- ("RIGHTPADDING", (0, 0), (-1, -1), 8),
- ("TOPPADDING", (0, 0), (-1, -1), 6),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
- ]
- )
- )
- elements.append(nivel_table)
- elements.append(PageBreak())
-
- # SECTION 3: ANΓLISIS POR MARCOS (Marco Analysis)
- elements.append(Paragraph("AnΓ‘lisis por Marcos y CategorΓas", h1))
- elements.append(Spacer(1, 0.2 * inch))
-
- chart_buffer = _create_marco_category_chart(
- requirements_list, attributes_by_requirement_id
- )
- chart_image = Image(chart_buffer, width=7 * inch, height=5 * inch)
- elements.append(chart_image)
- elements.append(PageBreak())
-
- # SECTION 4: DIMENSIONES DE SEGURIDAD (Security Dimensions)
- elements.append(Paragraph("AnΓ‘lisis por Dimensiones de Seguridad", h1))
- elements.append(Spacer(1, 0.2 * inch))
-
- radar_buffer = _create_dimensions_radar_chart(
- requirements_list, attributes_by_requirement_id
- )
- radar_image = Image(radar_buffer, width=6 * inch, height=6 * inch)
- elements.append(radar_image)
- elements.append(PageBreak())
-
- # SECTION 5: DISTRIBUCIΓN POR TIPO (Type Distribution)
- elements.append(Paragraph("DistribuciΓ³n por Tipo de Requisito", h1))
- elements.append(Spacer(1, 0.2 * inch))
-
- tipo_data = defaultdict(lambda: {"passed": 0, "total": 0})
- for requirement in requirements_list:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
- requirement_status = requirement["attributes"]["status"]
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if not metadata:
- continue
-
- m = metadata[0]
- tipo = _safe_getattr(m, "Tipo")
- tipo_data[tipo]["total"] += 1
- if requirement_status == StatusChoices.PASS:
- tipo_data[tipo]["passed"] += 1
-
- tipo_table_data = [["Tipo", "Cumplidos", "Total", "Porcentaje"]]
- for tipo in ENS_TIPO_ORDER:
- if tipo in tipo_data:
- data = tipo_data[tipo]
- percentage = (
- (data["passed"] / data["total"] * 100) if data["total"] > 0 else 0
- )
- tipo_table_data.append(
- [
- tipo.capitalize(),
- str(data["passed"]),
- str(data["total"]),
- f"{percentage:.1f}%",
- ]
- )
-
- tipo_table = Table(
- tipo_table_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch]
- )
- tipo_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.6)),
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 10),
- ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)),
- ("LEFTPADDING", (0, 0), (-1, -1), 8),
- ("RIGHTPADDING", (0, 0), (-1, -1), 8),
- ("TOPPADDING", (0, 0), (-1, -1), 6),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
- ]
- )
- )
- elements.append(tipo_table)
- elements.append(PageBreak())
-
- # SECTION 6: REQUISITOS CRΓTICOS NO CUMPLIDOS (Critical Failed Requirements)
- elements.append(Paragraph("Requisitos CrΓticos No Cumplidos", h1))
- elements.append(Spacer(1, 0.2 * inch))
-
- critical_failed = []
- for requirement in requirements_list:
- requirement_status = requirement["attributes"]["status"]
- if requirement_status == StatusChoices.FAIL:
- requirement_id = requirement["id"]
- req_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- ).get("attributes", {})
- metadata_list = req_attributes.get("req_attributes", [])
- if metadata_list:
- metadata = metadata_list[0]
- nivel = _safe_getattr(metadata, "Nivel", "")
- if nivel.lower() == "alto":
- critical_failed.append(
- {
- "requirement": requirement,
- "metadata": metadata,
- }
- )
-
- if not critical_failed:
- elements.append(
- Paragraph(
- "β
No se encontraron requisitos crΓticos no cumplidos.", normal
- )
- )
- else:
- elements.append(
- Paragraph(
- f"Se encontraron {len(critical_failed)} requisitos de nivel Alto que no cumplen:",
- normal,
- )
- )
- elements.append(Spacer(1, 0.3 * inch))
-
- critical_table_data = [["ID", "DescripciΓ³n", "Marco", "CategorΓa"]]
- for item in critical_failed:
- requirement_id = item["requirement"]["id"]
- description = item["requirement"]["attributes"]["description"]
- marco = _safe_getattr(item["metadata"], "Marco")
- categoria = _safe_getattr(item["metadata"], "Categoria")
-
- if len(description) > 60:
- description = description[:57] + "..."
-
- critical_table_data.append(
- [requirement_id, description, marco, categoria]
- )
-
- critical_table = Table(
- critical_table_data,
- colWidths=[1.5 * inch, 3.3 * inch, 1.5 * inch, 2 * inch],
- )
- critical_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.8, 0.2, 0.2)),
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- ("FONTSIZE", (0, 0), (-1, 0), 9),
- ("FONTNAME", (0, 1), (0, -1), "FiraCode"),
- ("FONTSIZE", (0, 1), (-1, -1), 8),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)),
- ("LEFTPADDING", (0, 0), (-1, -1), 6),
- ("RIGHTPADDING", (0, 0), (-1, -1), 6),
- ("TOPPADDING", (0, 0), (-1, -1), 6),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
- (
- "BACKGROUND",
- (1, 1),
- (-1, -1),
- colors.Color(0.98, 0.98, 0.98),
- ),
- ]
- )
- )
- elements.append(critical_table)
-
- elements.append(PageBreak())
-
- # SECTION 7: ΓNDICE DE REQUISITOS (Requirements Index)
- elements.append(Paragraph("Γndice de Requisitos", h1))
- elements.append(Spacer(1, 0.2 * inch))
-
- # Group by Marco β CategorΓa
- marco_categoria_index = defaultdict(lambda: defaultdict(list))
- for (
- requirement_id,
- requirement_attributes,
- ) in attributes_by_requirement_id.items():
- metadata = requirement_attributes["attributes"]["req_attributes"][0]
- marco = getattr(metadata, "Marco", "N/A")
- categoria = getattr(metadata, "Categoria", "N/A")
- id_grupo = getattr(metadata, "IdGrupoControl", "N/A")
-
- marco_categoria_index[marco][categoria].append(
- {
- "id": requirement_id,
- "id_grupo": id_grupo,
- "description": requirement_attributes["description"],
- }
- )
-
- for marco, categorias in sorted(marco_categoria_index.items()):
- elements.append(Paragraph(f"Marco: {marco.capitalize()}", h2))
- for categoria, requirements in sorted(categorias.items()):
- elements.append(Paragraph(f"CategorΓa: {categoria.capitalize()}", h3))
- for req in requirements:
- desc = req["description"]
- if len(desc) > 80:
- desc = desc[:77] + "..."
- elements.append(Paragraph(f"{req['id']} - {desc}", normal))
- elements.append(Spacer(1, 0.05 * inch))
-
- elements.append(PageBreak())
-
- # SECTION 8: DETALLE DE REQUISITOS (Detailed Requirements)
- elements.append(Paragraph("Detalle de Requisitos", h1))
- elements.append(Spacer(1, 0.2 * inch))
-
- # Filter: NO CUMPLE + MANUAL (if include_manual)
- filtered_requirements = [
- req
- for req in requirements_list
- if req["attributes"]["status"] == StatusChoices.FAIL
- or (include_manual and req["attributes"]["status"] == StatusChoices.MANUAL)
- ]
-
- if not filtered_requirements:
- elements.append(
- Paragraph("β
Todos los requisitos automΓ‘ticos cumplen.", normal)
- )
- else:
- elements.append(
- Paragraph(
- f"Se muestran {len(filtered_requirements)} requisitos que requieren atenciΓ³n:",
- normal,
- )
- )
- elements.append(Spacer(1, 0.2 * inch))
-
- # Collect check IDs to load
- check_ids_to_load = []
- for requirement in filtered_requirements:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
- check_ids = requirement_attributes.get("attributes", {}).get(
- "checks", []
- )
- check_ids_to_load.extend(check_ids)
-
- # Load findings on-demand
- logger.info(
- f"Loading findings on-demand for {len(filtered_requirements)} requirements"
- )
- findings_by_check_id = _load_findings_for_requirement_checks(
- tenant_id, scan_id, check_ids_to_load, prowler_provider, findings_cache
- )
-
- for requirement in filtered_requirements:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
- requirement_status = requirement["attributes"]["status"]
- requirement_description = requirement_attributes.get("description", "")
-
- # Requirement ID header in a box
- req_id_paragraph = Paragraph(requirement_id, h2)
- req_id_table = Table([[req_id_paragraph]], colWidths=[6.5 * inch])
- req_id_table.setStyle(
- TableStyle(
- [
- (
- "BACKGROUND",
- (0, 0),
- (0, 0),
- colors.Color(0.15, 0.35, 0.55),
- ),
- ("TEXTCOLOR", (0, 0), (0, 0), colors.white),
- ("ALIGN", (0, 0), (0, 0), "CENTER"),
- ("VALIGN", (0, 0), (0, 0), "MIDDLE"),
- ("LEFTPADDING", (0, 0), (-1, -1), 15),
- ("RIGHTPADDING", (0, 0), (-1, -1), 15),
- ("TOPPADDING", (0, 0), (-1, -1), 10),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
- ("BOX", (0, 0), (-1, -1), 2, colors.Color(0.2, 0.4, 0.6)),
- ]
- )
- )
- elements.append(req_id_table)
- elements.append(Spacer(1, 0.15 * inch))
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if metadata and len(metadata) > 0:
- m = metadata[0]
-
- # Create all badges
- status_component = _create_status_component(requirement_status)
- nivel = getattr(m, "Nivel", "N/A")
- nivel_badge = _create_ens_nivel_badge(nivel)
- tipo = getattr(m, "Tipo", "N/A")
- tipo_badge = _create_ens_tipo_badge(tipo)
-
- # Organize badges in a horizontal table (2 rows x 2 cols)
- badges_table = Table(
- [[status_component, nivel_badge], [tipo_badge]],
- colWidths=[3.25 * inch, 3.25 * inch],
- )
- badges_table.setStyle(
- TableStyle(
- [
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("LEFTPADDING", (0, 0), (-1, -1), 5),
- ("RIGHTPADDING", (0, 0), (-1, -1), 5),
- ("TOPPADDING", (0, 0), (-1, -1), 5),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
- ]
- )
- )
- elements.append(badges_table)
- elements.append(Spacer(1, 0.15 * inch))
-
- # Dimensiones badges (if present)
- dimensiones = getattr(m, "Dimensiones", [])
- if dimensiones:
- dim_label = Paragraph("Dimensiones:", normal)
- dim_badges = _create_ens_dimension_badges(dimensiones)
- dim_table = Table(
- [[dim_label, dim_badges]], colWidths=[1.5 * inch, 5 * inch]
- )
- dim_table.setStyle(
- TableStyle(
- [
- ("ALIGN", (0, 0), (0, 0), "LEFT"),
- ("ALIGN", (1, 0), (1, 0), "LEFT"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ]
- )
- )
- elements.append(dim_table)
- elements.append(Spacer(1, 0.15 * inch))
-
- # Requirement details in a clean table
- details_data = [
- ["DescripciΓ³n:", Paragraph(requirement_description, normal)],
- ["Marco:", Paragraph(getattr(m, "Marco", "N/A"), normal)],
- [
- "CategorΓa:",
- Paragraph(getattr(m, "Categoria", "N/A"), normal),
- ],
- [
- "ID Grupo Control:",
- Paragraph(getattr(m, "IdGrupoControl", "N/A"), normal),
- ],
- [
- "DescripciΓ³n del Control:",
- Paragraph(getattr(m, "DescripcionControl", "N/A"), normal),
- ],
- ]
- details_table = Table(
- details_data, colWidths=[2.2 * inch, 4.5 * inch]
- )
- details_table.setStyle(
- TableStyle(
- [
- (
- "BACKGROUND",
- (0, 0),
- (0, -1),
- colors.Color(0.9, 0.93, 0.96),
- ),
- (
- "TEXTCOLOR",
- (0, 0),
- (0, -1),
- colors.Color(0.2, 0.2, 0.2),
- ),
- ("FONTNAME", (0, 0), (0, -1), "FiraCode"),
- ("FONTSIZE", (0, 0), (-1, -1), 10),
- ("ALIGN", (0, 0), (0, -1), "LEFT"),
- ("VALIGN", (0, 0), (-1, -1), "TOP"),
- (
- "GRID",
- (0, 0),
- (-1, -1),
- 0.5,
- colors.Color(0.7, 0.8, 0.9),
- ),
- ("LEFTPADDING", (0, 0), (-1, -1), 8),
- ("RIGHTPADDING", (0, 0), (-1, -1), 8),
- ("TOPPADDING", (0, 0), (-1, -1), 6),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
- ]
- )
- )
- elements.append(details_table)
- elements.append(Spacer(1, 0.2 * inch))
-
- # Findings for checks
- requirement_check_ids = requirement_attributes.get(
- "attributes", {}
- ).get("checks", [])
- for check_id in requirement_check_ids:
- elements.append(Paragraph(f"Check: {check_id}", h2))
- elements.append(Spacer(1, 0.1 * inch))
-
- check_findings = findings_by_check_id.get(check_id, [])
-
- if not check_findings:
- elements.append(
- Paragraph(
- "- No hay informaciΓ³n disponible para este check",
- normal,
- )
- )
- else:
- findings_table_data = [
- ["Finding", "Resource name", "Severity", "Status", "Region"]
- ]
- for finding_output in check_findings:
- check_metadata = getattr(finding_output, "metadata", {})
- finding_title = getattr(
- check_metadata,
- "CheckTitle",
- getattr(finding_output, "check_id", ""),
- )
- resource_name = getattr(finding_output, "resource_name", "")
- if not resource_name:
- resource_name = getattr(
- finding_output, "resource_uid", ""
- )
- severity = getattr(
- check_metadata, "Severity", ""
- ).capitalize()
- finding_status = getattr(
- finding_output, "status", ""
- ).upper()
- region = getattr(finding_output, "region", "global")
-
- findings_table_data.append(
- [
- Paragraph(finding_title, normal_center),
- Paragraph(resource_name, normal_center),
- Paragraph(severity, normal_center),
- Paragraph(finding_status, normal_center),
- Paragraph(region, normal_center),
- ]
- )
-
- findings_table = Table(
- findings_table_data,
- colWidths=[
- 2.5 * inch,
- 3 * inch,
- 0.9 * inch,
- 0.9 * inch,
- 0.9 * inch,
- ],
- )
- findings_table.setStyle(
- TableStyle(
- [
- (
- "BACKGROUND",
- (0, 0),
- (-1, 0),
- colors.Color(0.2, 0.4, 0.6),
- ),
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- ("ALIGN", (0, 0), (0, 0), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 9),
- (
- "GRID",
- (0, 0),
- (-1, -1),
- 0.1,
- colors.Color(0.7, 0.8, 0.9),
- ),
- ("LEFTPADDING", (0, 0), (0, 0), 0),
- ("RIGHTPADDING", (0, 0), (0, 0), 0),
- ("TOPPADDING", (0, 0), (-1, -1), 4),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
- ]
- )
- )
- elements.append(findings_table)
-
- elements.append(Spacer(1, 0.1 * inch))
-
- elements.append(PageBreak())
-
- # Build the PDF
- logger.info("Building PDF...")
- doc.build(
- elements,
- onFirstPage=partial(_add_pdf_footer, compliance_name=compliance_name),
- onLaterPages=partial(_add_pdf_footer, compliance_name=compliance_name),
- )
- except Exception as e:
- tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown"
- logger.error(f"Error building ENS report, line {tb_lineno} -- {e}")
- raise e
+ generator = ENSReportGenerator(FRAMEWORK_REGISTRY["ens"])
+
+ generator.generate(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ compliance_id=compliance_id,
+ output_path=output_path,
+ provider_id=provider_id,
+ provider_obj=provider_obj,
+ requirement_statistics=requirement_statistics,
+ findings_cache=findings_cache,
+ include_manual=include_manual,
+ )
def generate_nis2_report(
@@ -2897,552 +112,39 @@ def generate_nis2_report(
provider_id: str,
only_failed: bool = True,
include_manual: bool = False,
- provider_obj=None,
+ provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
) -> None:
"""
Generate a PDF compliance report for NIS2 Directive (EU) 2022/2555.
- This function creates a comprehensive PDF report containing:
- - Compliance overview and metadata
- - Executive summary with overall compliance score
- - Section analysis with horizontal bar chart
- - SubSection breakdown table
- - Critical failed requirements
- - Requirements index organized by section and subsection
- - Detailed findings for failed requirements
-
Args:
- tenant_id (str): The tenant ID for Row-Level Security context.
- scan_id (str): ID of the scan executed by Prowler.
- compliance_id (str): ID of the compliance framework (e.g., "nis2_aws").
- output_path (str): Output PDF file path (e.g., "/tmp/nis2_report.pdf").
- provider_id (str): Provider ID for the scan.
- only_failed (bool): If True, only requirements with status "FAIL" will be included
- in the detailed requirements section. Defaults to True.
- include_manual (bool): If True, includes MANUAL requirements in the detailed findings
- section along with FAIL requirements. Defaults to True.
- provider_obj (Provider, optional): Pre-fetched Provider object to avoid duplicate queries.
- If None, the provider will be fetched from the database.
- requirement_statistics (dict, optional): Pre-aggregated requirement statistics to avoid
- duplicate database aggregations. If None, statistics will be aggregated from the database.
- findings_cache (dict, optional): Cache of already loaded findings to avoid duplicate queries.
- If None, findings will be loaded from the database.
-
- Raises:
- Exception: If any error occurs during PDF generation, it will be logged and re-raised.
+ 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., "nis2_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.
+ include_manual: If True, include manual requirements in detailed section.
+ provider_obj: Pre-fetched Provider object to avoid duplicate queries.
+ requirement_statistics: Pre-aggregated requirement statistics.
+ findings_cache: Cache of already loaded findings to avoid duplicate queries.
"""
- logger.info(
- f"Generating NIS2 report for scan {scan_id} with provider {provider_id}"
+ generator = NIS2ReportGenerator(FRAMEWORK_REGISTRY["nis2"])
+
+ generator.generate(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ compliance_id=compliance_id,
+ output_path=output_path,
+ provider_id=provider_id,
+ provider_obj=provider_obj,
+ requirement_statistics=requirement_statistics,
+ findings_cache=findings_cache,
+ only_failed=only_failed,
+ include_manual=include_manual,
)
- try:
- # Get PDF styles
- pdf_styles = _create_pdf_styles()
- title_style = pdf_styles["title"]
- h1 = pdf_styles["h1"]
- h2 = pdf_styles["h2"]
- h3 = pdf_styles["h3"]
- normal = pdf_styles["normal"]
- normal_center = pdf_styles["normal_center"]
-
- # Get compliance and provider information
- with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
- # Use provided provider_obj or fetch from database
- if provider_obj is None:
- provider_obj = Provider.objects.get(id=provider_id)
-
- prowler_provider = initialize_prowler_provider(provider_obj)
- provider_type = provider_obj.provider
-
- frameworks_bulk = Compliance.get_bulk(provider_type)
- compliance_obj = frameworks_bulk[compliance_id]
- compliance_framework = _safe_getattr(compliance_obj, "Framework")
- compliance_version = _safe_getattr(compliance_obj, "Version")
- compliance_name = _safe_getattr(compliance_obj, "Name")
- compliance_description = _safe_getattr(compliance_obj, "Description", "")
-
- # Aggregate requirement statistics from database
- if requirement_statistics is None:
- logger.info(f"Aggregating requirement statistics for scan {scan_id}")
- requirement_statistics_by_check_id = (
- _aggregate_requirement_statistics_from_database(tenant_id, scan_id)
- )
- else:
- logger.info(
- f"Reusing pre-aggregated requirement statistics for scan {scan_id}"
- )
- requirement_statistics_by_check_id = requirement_statistics
-
- # Calculate requirements data using aggregated statistics
- attributes_by_requirement_id, requirements_list = (
- _calculate_requirements_data_from_statistics(
- compliance_obj, requirement_statistics_by_check_id
- )
- )
-
- # Initialize PDF document
- doc = SimpleDocTemplate(
- output_path,
- pagesize=letter,
- title="NIS2 Compliance Report - Prowler",
- author="Prowler",
- subject=f"Compliance Report for {compliance_framework}",
- creator="Prowler Engineering Team",
- keywords=f"compliance,{compliance_framework},security,nis2,prowler,eu",
- )
-
- elements = []
-
- # SECTION 1: Cover Page
- # Create logos side by side
- prowler_logo_path = os.path.join(
- os.path.dirname(__file__), "../assets/img/prowler_logo.png"
- )
- nis2_logo_path = os.path.join(
- os.path.dirname(__file__), "../assets/img/nis2_logo.png"
- )
-
- prowler_logo = Image(
- prowler_logo_path,
- width=3.5 * inch,
- height=0.7 * inch,
- )
- nis2_logo = Image(
- nis2_logo_path,
- width=2.3 * inch,
- height=1.5 * inch,
- )
-
- # Create table with both logos
- logos_table = Table(
- [[prowler_logo, nis2_logo]], colWidths=[4 * inch, 2.5 * inch]
- )
- logos_table.setStyle(
- TableStyle(
- [
- ("ALIGN", (0, 0), (0, 0), "LEFT"),
- ("ALIGN", (1, 0), (1, 0), "RIGHT"),
- ("VALIGN", (0, 0), (0, 0), "MIDDLE"), # Prowler logo middle
- ("VALIGN", (1, 0), (1, 0), "MIDDLE"), # NIS2 logo middle
- ]
- )
- )
- elements.append(logos_table)
- elements.append(Spacer(1, 0.3 * inch))
-
- # Title
- title = Paragraph(
- "NIS2 Compliance Report
Directive (EU) 2022/2555",
- title_style,
- )
- elements.append(title)
- elements.append(Spacer(1, 0.3 * inch))
-
- # Compliance metadata table
- provider_alias = provider_obj.alias or "N/A"
- metadata_data = [
- ["Framework:", compliance_framework],
- ["Name:", Paragraph(compliance_name, normal_center)],
- ["Version:", compliance_version or "N/A"],
- ["Provider:", provider_type.upper()],
- ["Account ID:", provider_obj.uid],
- ["Alias:", provider_alias],
- ["Scan ID:", scan_id],
- ["Description:", Paragraph(compliance_description, normal_center)],
- ]
-
- metadata_table = Table(metadata_data, colWidths=[COL_WIDTH_XLARGE, 4 * inch])
- metadata_table.setStyle(_create_info_table_style())
- elements.append(metadata_table)
- elements.append(PageBreak())
-
- # SECTION 2: Executive Summary
- elements.append(Paragraph("Executive Summary", h1))
- elements.append(Spacer(1, 0.1 * inch))
-
- # Calculate overall statistics
- total_requirements = len(requirements_list)
- passed_requirements = sum(
- 1
- for req in requirements_list
- if req["attributes"].get("status") == StatusChoices.PASS
- )
- failed_requirements = sum(
- 1
- for req in requirements_list
- if req["attributes"].get("status") == StatusChoices.FAIL
- )
- manual_requirements = sum(
- 1
- for req in requirements_list
- if req["attributes"].get("status") == StatusChoices.MANUAL
- )
-
- overall_compliance = (
- (passed_requirements / (passed_requirements + failed_requirements) * 100)
- if (passed_requirements + failed_requirements) > 0
- else 100
- )
-
- # Summary statistics table
- summary_data = [
- ["Metric", "Value"],
- ["Total Requirements", str(total_requirements)],
- ["Passed β", str(passed_requirements)],
- ["Failed β", str(failed_requirements)],
- ["Manual β", str(manual_requirements)],
- ["Overall Compliance", f"{overall_compliance:.1f}%"],
- ]
-
- summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch])
- summary_table.setStyle(
- TableStyle(
- [
- # Header row
- ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY),
- ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
- # Status-specific colors for left column
- ("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE), # Passed row
- ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE),
- ("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK), # Failed row
- ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE),
- ("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY), # Manual row
- ("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE),
- # General styling
- ("ALIGN", (0, 0), (-1, -1), "CENTER"),
- ("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"),
- ("FONTSIZE", (0, 0), (-1, 0), 12),
- ("FONTSIZE", (0, 1), (-1, -1), 10),
- ("BOTTOMPADDING", (0, 0), (-1, 0), 10),
- ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY),
- # Alternating backgrounds for right column
- (
- "ROWBACKGROUNDS",
- (1, 1),
- (1, -1),
- [COLOR_WHITE, COLOR_NIS2_BG_BLUE],
- ),
- ]
- )
- )
- elements.append(summary_table)
- elements.append(PageBreak())
-
- # SECTION 3: Compliance by Section Analysis
- elements.append(Paragraph("Compliance by Section", h1))
- elements.append(Spacer(1, 0.1 * inch))
-
- elements.append(
- Paragraph(
- "The following chart shows compliance percentage for each main section of the NIS2 directive:",
- normal_center,
- )
- )
- elements.append(Spacer(1, 0.1 * inch))
-
- # Create section chart
- section_chart_buffer = _create_nis2_section_chart(
- requirements_list, attributes_by_requirement_id
- )
- section_chart_buffer.seek(0)
- section_chart = Image(section_chart_buffer, width=6.5 * inch, height=5 * inch)
- elements.append(section_chart)
- elements.append(PageBreak())
-
- # SECTION 4: SubSection Breakdown
- elements.append(Paragraph("SubSection Breakdown", h1))
- elements.append(Spacer(1, 0.1 * inch))
-
- subsection_table = _create_nis2_subsection_table(
- requirements_list, attributes_by_requirement_id
- )
- elements.append(subsection_table)
- elements.append(PageBreak())
-
- # SECTION 5: Requirements Index
- elements.append(Paragraph("Requirements Index", h1))
- elements.append(Spacer(1, 0.1 * inch))
-
- index_elements = _create_nis2_requirements_index(
- requirements_list, attributes_by_requirement_id, h2, h3, normal
- )
- elements.extend(index_elements)
- elements.append(PageBreak())
-
- # SECTION 6: Detailed Findings
- elements.append(Paragraph("Detailed Findings", h1))
- elements.append(Spacer(1, 0.2 * inch))
-
- # Filter requirements for detailed findings (FAIL + MANUAL if include_manual)
- filtered_requirements = [
- req
- for req in requirements_list
- if req["attributes"]["status"] == StatusChoices.FAIL
- or (include_manual and req["attributes"]["status"] == StatusChoices.MANUAL)
- ]
-
- if not filtered_requirements:
- elements.append(
- Paragraph("β
All automatic requirements are compliant.", normal)
- )
- else:
- elements.append(
- Paragraph(
- f"Showing {len(filtered_requirements)} requirements that need attention:",
- normal,
- )
- )
- elements.append(Spacer(1, 0.2 * inch))
-
- # Collect check IDs to load
- check_ids_to_load = []
- for requirement in filtered_requirements:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
- check_ids = requirement_attributes.get("attributes", {}).get(
- "checks", []
- )
- check_ids_to_load.extend(check_ids)
-
- # Load findings on-demand
- logger.info(
- f"Loading findings on-demand for {len(filtered_requirements)} NIS2 requirements"
- )
- findings_by_check_id = _load_findings_for_requirement_checks(
- tenant_id, scan_id, check_ids_to_load, prowler_provider, findings_cache
- )
-
- for requirement in filtered_requirements:
- requirement_id = requirement["id"]
- requirement_attributes = attributes_by_requirement_id.get(
- requirement_id, {}
- )
- requirement_status = requirement["attributes"]["status"]
- requirement_description = requirement_attributes.get("description", "")
-
- # Requirement ID header in a box
- req_id_paragraph = Paragraph(f"Requirement: {requirement_id}", h2)
- req_id_table = Table([[req_id_paragraph]], colWidths=[6.5 * inch])
- req_id_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), COLOR_NIS2_PRIMARY),
- ("TEXTCOLOR", (0, 0), (0, 0), colors.white),
- ("ALIGN", (0, 0), (0, 0), "CENTER"),
- ("VALIGN", (0, 0), (0, 0), "MIDDLE"),
- ("LEFTPADDING", (0, 0), (-1, -1), 15),
- ("RIGHTPADDING", (0, 0), (-1, -1), 15),
- ("TOPPADDING", (0, 0), (-1, -1), 10),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
- ("BOX", (0, 0), (-1, -1), 2, COLOR_NIS2_SECONDARY),
- ]
- )
- )
- elements.append(req_id_table)
- elements.append(Spacer(1, 0.15 * inch))
-
- metadata = requirement_attributes.get("attributes", {}).get(
- "req_attributes", []
- )
- if metadata:
- m = metadata[0]
- section = _safe_getattr(m, "Section", "Unknown")
- subsection = _safe_getattr(m, "SubSection", "Unknown")
- service = _safe_getattr(m, "Service", "generic")
-
- # Status badge
- status_text = (
- "β PASS"
- if requirement_status == StatusChoices.PASS
- else (
- "β FAIL"
- if requirement_status == StatusChoices.FAIL
- else "β MANUAL"
- )
- )
- status_color = (
- COLOR_SAFE
- if requirement_status == StatusChoices.PASS
- else (
- COLOR_HIGH_RISK
- if requirement_status == StatusChoices.FAIL
- else COLOR_DARK_GRAY
- )
- )
-
- status_badge = Paragraph(
- f"{status_text}",
- ParagraphStyle(
- "status_badge",
- parent=normal,
- alignment=1,
- textColor=colors.white,
- fontSize=14,
- ),
- )
- status_table = Table([[status_badge]], colWidths=[6.5 * inch])
- status_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (0, 0), status_color),
- ("ALIGN", (0, 0), (0, 0), "CENTER"),
- ("VALIGN", (0, 0), (0, 0), "MIDDLE"),
- ("TOPPADDING", (0, 0), (-1, -1), 8),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
- ]
- )
- )
- elements.append(status_table)
- elements.append(Spacer(1, 0.15 * inch))
-
- # Requirement details table
- details_data = [
- [
- "Description:",
- Paragraph(requirement_description, normal_center),
- ],
- ["Section:", Paragraph(section, normal_center)],
- ["SubSection:", Paragraph(subsection, normal_center)],
- ["Service:", service],
- ]
- details_table = Table(
- details_data, colWidths=[2.2 * inch, 4.5 * inch]
- )
- details_table.setStyle(
- TableStyle(
- [
- (
- "BACKGROUND",
- (0, 0),
- (0, -1),
- COLOR_NIS2_BG_BLUE,
- ),
- ("TEXTCOLOR", (0, 0), (0, -1), COLOR_GRAY),
- ("FONTNAME", (0, 0), (0, -1), "FiraCode"),
- ("FONTSIZE", (0, 0), (-1, -1), 10),
- ("ALIGN", (0, 0), (0, -1), "LEFT"),
- ("VALIGN", (0, 0), (-1, -1), "TOP"),
- ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY),
- ("LEFTPADDING", (0, 0), (-1, -1), 8),
- ("RIGHTPADDING", (0, 0), (-1, -1), 8),
- ("TOPPADDING", (0, 0), (-1, -1), 6),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
- ]
- )
- )
- elements.append(details_table)
- elements.append(Spacer(1, 0.2 * inch))
-
- # Findings for checks
- requirement_check_ids = requirement_attributes.get(
- "attributes", {}
- ).get("checks", [])
- for check_id in requirement_check_ids:
- elements.append(Paragraph(f"Check: {check_id}", h3))
- elements.append(Spacer(1, 0.1 * inch))
-
- check_findings = findings_by_check_id.get(check_id, [])
-
- if not check_findings:
- elements.append(
- Paragraph(
- "- No information available for this check", normal
- )
- )
- else:
- findings_table_data = [
- ["Finding", "Resource name", "Severity", "Status", "Region"]
- ]
- for finding_output in check_findings:
- check_metadata = getattr(finding_output, "metadata", {})
- finding_title = getattr(
- check_metadata,
- "CheckTitle",
- getattr(finding_output, "check_id", ""),
- )
- resource_name = getattr(finding_output, "resource_name", "")
- if not resource_name:
- resource_name = getattr(
- finding_output, "resource_uid", ""
- )
- severity = getattr(
- check_metadata, "Severity", ""
- ).capitalize()
- finding_status = getattr(
- finding_output, "status", ""
- ).upper()
- region = getattr(finding_output, "region", "global")
-
- findings_table_data.append(
- [
- Paragraph(finding_title, normal_center),
- Paragraph(resource_name, normal_center),
- Paragraph(severity, normal_center),
- Paragraph(finding_status, normal_center),
- Paragraph(region, normal_center),
- ]
- )
-
- findings_table = Table(
- findings_table_data,
- colWidths=[
- 2.5 * inch,
- 3 * inch,
- 0.9 * inch,
- 0.9 * inch,
- 0.9 * inch,
- ],
- )
- findings_table.setStyle(
- TableStyle(
- [
- (
- "BACKGROUND",
- (0, 0),
- (-1, 0),
- COLOR_NIS2_PRIMARY,
- ),
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
- ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
- ("ALIGN", (0, 0), (0, 0), "CENTER"),
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
- ("FONTSIZE", (0, 0), (-1, -1), 9),
- ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY),
- (
- "ROWBACKGROUNDS",
- (0, 1),
- (-1, -1),
- [colors.white, COLOR_NIS2_BG_BLUE],
- ),
- ("LEFTPADDING", (0, 0), (-1, -1), 5),
- ("RIGHTPADDING", (0, 0), (-1, -1), 5),
- ("TOPPADDING", (0, 0), (-1, -1), 5),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
- ]
- )
- )
- elements.append(findings_table)
-
- elements.append(Spacer(1, 0.15 * inch))
-
- elements.append(Spacer(1, 0.2 * inch))
-
- # Build the PDF
- logger.info("Building NIS2 PDF...")
- doc.build(
- elements,
- onFirstPage=partial(_add_pdf_footer, compliance_name=compliance_name),
- onLaterPages=partial(_add_pdf_footer, compliance_name=compliance_name),
- )
- logger.info(f"NIS2 report successfully generated at {output_path}")
-
- except Exception as e:
- tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown"
- logger.error(f"Error building NIS2 report, line {tb_lineno} -- {e}")
- raise e
def generate_compliance_reports(
@@ -3459,58 +161,45 @@ def generate_compliance_reports(
only_failed_nis2: bool = True,
) -> dict[str, dict[str, bool | str]]:
"""
- Generate multiple compliance reports (ThreatScore, ENS, and/or NIS2) with shared database queries.
+ Generate multiple compliance reports with shared database queries.
This function optimizes the generation of multiple reports by:
- Fetching the provider object once
- Aggregating requirement statistics once (shared across all reports)
- Reusing compliance framework data when possible
- This can reduce database queries by up to 50-70% when generating multiple reports.
-
Args:
- tenant_id (str): The tenant ID for Row-Level Security context.
- scan_id (str): The ID of the scan to generate reports for.
- provider_id (str): The ID of the provider used in the scan.
- generate_threatscore (bool): Whether to generate ThreatScore report. Defaults to True.
- generate_ens (bool): Whether to generate ENS report. Defaults to True.
- generate_nis2 (bool): Whether to generate NIS2 report. Defaults to True.
- only_failed_threatscore (bool): For ThreatScore, only include failed requirements. Defaults to True.
- min_risk_level_threatscore (int): Minimum risk level for ThreatScore critical requirements. Defaults to 4.
- include_manual_ens (bool): For ENS, include manual requirements. Defaults to True.
- only_failed_nis2 (bool): For NIS2, only include failed requirements. Defaults to True.
+ tenant_id: The tenant ID for Row-Level Security context.
+ scan_id: The ID of the scan to generate reports for.
+ provider_id: The ID of the provider used in the scan.
+ generate_threatscore: Whether to generate ThreatScore report.
+ generate_ens: Whether to generate ENS report.
+ generate_nis2: Whether to generate NIS2 report.
+ only_failed_threatscore: For ThreatScore, only include failed requirements.
+ min_risk_level_threatscore: Minimum risk level for ThreatScore critical requirements.
+ include_manual_ens: For ENS, include manual requirements.
+ include_manual_nis2: For NIS2, include manual requirements.
+ only_failed_nis2: For NIS2, only include failed requirements.
Returns:
- dict[str, dict[str, bool | str]]: Dictionary with results for each report:
- {
- 'threatscore': {'upload': bool, 'path': str, 'error': str (optional)},
- 'ens': {'upload': bool, 'path': str, 'error': str (optional)},
- 'nis2': {'upload': bool, 'path': str, 'error': str (optional)}
- }
-
- Example:
- >>> results = generate_compliance_reports(
- ... tenant_id="tenant-123",
- ... scan_id="scan-456",
- ... provider_id="provider-789",
- ... generate_threatscore=True,
- ... generate_ens=True,
- ... generate_nis2=True
- ... )
- >>> print(results['threatscore']['upload'])
- True
+ Dictionary with results for each report type.
"""
logger.info(
- f"Generating compliance reports for scan {scan_id} with provider {provider_id}"
- f" (ThreatScore: {generate_threatscore}, ENS: {generate_ens}, NIS2: {generate_nis2})"
+ "Generating compliance reports for scan %s with provider %s"
+ " (ThreatScore: %s, ENS: %s, NIS2: %s)",
+ scan_id,
+ provider_id,
+ generate_threatscore,
+ generate_ens,
+ generate_nis2,
)
results = {}
- # Validate that the scan has findings and get provider info (shared query)
+ # Validate that the scan has findings and get provider info
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
if not ScanSummary.objects.filter(scan_id=scan_id).exists():
- logger.info(f"No findings found for scan {scan_id}")
+ logger.info("No findings found for scan %s", scan_id)
if generate_threatscore:
results["threatscore"] = {"upload": False, "path": ""}
if generate_ens:
@@ -3519,7 +208,6 @@ def generate_compliance_reports(
results["nis2"] = {"upload": False, "path": ""}
return results
- # Fetch provider once (optimization)
provider_obj = Provider.objects.get(id=provider_id)
provider_uid = provider_obj.uid
provider_type = provider_obj.provider
@@ -3533,43 +221,36 @@ def generate_compliance_reports(
"kubernetes",
"alibabacloud",
]:
- logger.info(
- f"Provider {provider_id} ({provider_type}) is not supported for ThreatScore report"
- )
+ logger.info("Provider %s not supported for ThreatScore report", provider_type)
results["threatscore"] = {"upload": False, "path": ""}
generate_threatscore = False
if generate_ens and provider_type not in ["aws", "azure", "gcp"]:
- logger.info(
- f"Provider {provider_id} ({provider_type}) is not supported for ENS report"
- )
+ logger.info("Provider %s not supported for ENS report", provider_type)
results["ens"] = {"upload": False, "path": ""}
generate_ens = False
if generate_nis2 and provider_type not in ["aws", "azure", "gcp"]:
- logger.info(
- f"Provider {provider_id} ({provider_type}) is not supported for NIS2 report"
- )
+ logger.info("Provider %s not supported for NIS2 report", provider_type)
results["nis2"] = {"upload": False, "path": ""}
generate_nis2 = False
- # If no reports to generate, return early
if not generate_threatscore and not generate_ens and not generate_nis2:
return results
- # Aggregate requirement statistics once (major optimization)
+ # Aggregate requirement statistics once
logger.info(
- f"Aggregating requirement statistics once for all reports (scan {scan_id})"
+ "Aggregating requirement statistics once for all reports (scan %s)", scan_id
)
requirement_statistics = _aggregate_requirement_statistics_from_database(
tenant_id, scan_id
)
- # Create shared findings cache (major optimization for findings queries)
+ # Create shared findings cache
findings_cache = {}
- logger.info("Created shared findings cache for both reports")
+ logger.info("Created shared findings cache for all reports")
- # Generate output directories for each compliance framework
+ # Generate output directories
try:
logger.info("Generating output directories")
threatscore_path = _generate_compliance_output_directory(
@@ -3593,10 +274,9 @@ def generate_compliance_reports(
scan_id,
compliance_framework="nis2",
)
- # Extract base scan directory for cleanup (parent of threatscore directory)
out_dir = str(Path(threatscore_path).parent.parent)
except Exception as e:
- logger.error(f"Error generating output directory: {e}")
+ logger.error("Error generating output directory: %s", e)
error_dict = {"error": str(e), "upload": False, "path": ""}
if generate_threatscore:
results["threatscore"] = error_dict.copy()
@@ -3611,7 +291,8 @@ def generate_compliance_reports(
compliance_id_threatscore = f"prowler_threatscore_{provider_type}"
pdf_path_threatscore = f"{threatscore_path}_threatscore_report.pdf"
logger.info(
- f"Generating ThreatScore report with compliance {compliance_id_threatscore}"
+ "Generating ThreatScore report with compliance %s",
+ compliance_id_threatscore,
)
try:
@@ -3623,13 +304,13 @@ def generate_compliance_reports(
provider_id=provider_id,
only_failed=only_failed_threatscore,
min_risk_level=min_risk_level_threatscore,
- provider_obj=provider_obj, # Reuse provider object
- requirement_statistics=requirement_statistics, # Reuse statistics
- findings_cache=findings_cache, # Share findings cache
+ provider_obj=provider_obj,
+ requirement_statistics=requirement_statistics,
+ findings_cache=findings_cache,
)
# Compute and store ThreatScore metrics snapshot
- logger.info(f"Computing ThreatScore metrics for scan {scan_id}")
+ logger.info("Computing ThreatScore metrics for scan %s", scan_id)
try:
metrics = compute_threatscore_metrics(
tenant_id=tenant_id,
@@ -3639,9 +320,7 @@ def generate_compliance_reports(
min_risk_level=min_risk_level_threatscore,
)
- # Create snapshot in database
with rls_transaction(tenant_id):
- # Get previous snapshot for the same provider to calculate delta
previous_snapshot = (
ThreatScoreSnapshot.objects.filter(
tenant_id=tenant_id,
@@ -3652,7 +331,6 @@ def generate_compliance_reports(
.first()
)
- # Calculate score delta (improvement)
score_delta = None
if previous_snapshot:
score_delta = metrics["overall_score"] - float(
@@ -3683,12 +361,10 @@ def generate_compliance_reports(
else ""
)
logger.info(
- f"ThreatScore snapshot created with ID {snapshot.id} "
- f"(score: {snapshot.overall_score}%{delta_msg})"
+ f"ThreatScore snapshot created with ID {snapshot.id} (score: {snapshot.overall_score}%{delta_msg})",
)
except Exception as e:
- # Log error but don't fail the job if snapshot creation fails
- logger.error(f"Error creating ThreatScore snapshot: {e}")
+ logger.error("Error creating ThreatScore snapshot: %s", e)
upload_uri_threatscore = _upload_to_s3(
tenant_id,
@@ -3702,20 +378,20 @@ def generate_compliance_reports(
"upload": True,
"path": upload_uri_threatscore,
}
- logger.info(f"ThreatScore report uploaded to {upload_uri_threatscore}")
+ logger.info("ThreatScore report uploaded to %s", upload_uri_threatscore)
else:
results["threatscore"] = {"upload": False, "path": out_dir}
- logger.warning(f"ThreatScore report saved locally at {out_dir}")
+ logger.warning("ThreatScore report saved locally at %s", out_dir)
except Exception as e:
- logger.error(f"Error generating ThreatScore report: {e}")
+ logger.error("Error generating ThreatScore report: %s", e)
results["threatscore"] = {"upload": False, "path": "", "error": str(e)}
# Generate ENS report
if generate_ens:
compliance_id_ens = f"ens_rd2022_{provider_type}"
pdf_path_ens = f"{ens_path}_ens_report.pdf"
- logger.info(f"Generating ENS report with compliance {compliance_id_ens}")
+ logger.info("Generating ENS report with compliance %s", compliance_id_ens)
try:
generate_ens_report(
@@ -3725,34 +401,31 @@ def generate_compliance_reports(
output_path=pdf_path_ens,
provider_id=provider_id,
include_manual=include_manual_ens,
- provider_obj=provider_obj, # Reuse provider object
- requirement_statistics=requirement_statistics, # Reuse statistics
- findings_cache=findings_cache, # Share findings cache
+ provider_obj=provider_obj,
+ requirement_statistics=requirement_statistics,
+ findings_cache=findings_cache,
)
upload_uri_ens = _upload_to_s3(
- tenant_id,
- scan_id,
- pdf_path_ens,
- f"ens/{Path(pdf_path_ens).name}",
+ tenant_id, scan_id, pdf_path_ens, f"ens/{Path(pdf_path_ens).name}"
)
if upload_uri_ens:
results["ens"] = {"upload": True, "path": upload_uri_ens}
- logger.info(f"ENS report uploaded to {upload_uri_ens}")
+ logger.info("ENS report uploaded to %s", upload_uri_ens)
else:
results["ens"] = {"upload": False, "path": out_dir}
- logger.warning(f"ENS report saved locally at {out_dir}")
+ logger.warning("ENS report saved locally at %s", out_dir)
except Exception as e:
- logger.error(f"Error generating ENS report: {e}")
+ logger.error("Error generating ENS report: %s", e)
results["ens"] = {"upload": False, "path": "", "error": str(e)}
# Generate NIS2 report
if generate_nis2:
compliance_id_nis2 = f"nis2_{provider_type}"
pdf_path_nis2 = f"{nis2_path}_nis2_report.pdf"
- logger.info(f"Generating NIS2 report with compliance {compliance_id_nis2}")
+ logger.info("Generating NIS2 report with compliance %s", compliance_id_nis2)
try:
generate_nis2_report(
@@ -3763,27 +436,24 @@ def generate_compliance_reports(
provider_id=provider_id,
only_failed=only_failed_nis2,
include_manual=include_manual_nis2,
- provider_obj=provider_obj, # Reuse provider object
- requirement_statistics=requirement_statistics, # Reuse statistics
- findings_cache=findings_cache, # Share findings cache
+ provider_obj=provider_obj,
+ requirement_statistics=requirement_statistics,
+ findings_cache=findings_cache,
)
upload_uri_nis2 = _upload_to_s3(
- tenant_id,
- scan_id,
- pdf_path_nis2,
- f"nis2/{Path(pdf_path_nis2).name}",
+ tenant_id, scan_id, pdf_path_nis2, f"nis2/{Path(pdf_path_nis2).name}"
)
if upload_uri_nis2:
results["nis2"] = {"upload": True, "path": upload_uri_nis2}
- logger.info(f"NIS2 report uploaded to {upload_uri_nis2}")
+ logger.info("NIS2 report uploaded to %s", upload_uri_nis2)
else:
results["nis2"] = {"upload": False, "path": out_dir}
- logger.warning(f"NIS2 report saved locally at {out_dir}")
+ logger.warning("NIS2 report saved locally at %s", out_dir)
except Exception as e:
- logger.error(f"Error generating NIS2 report: {e}")
+ logger.error("Error generating NIS2 report: %s", e)
results["nis2"] = {"upload": False, "path": "", "error": str(e)}
# Clean up temporary files if all reports were uploaded successfully
@@ -3796,11 +466,11 @@ def generate_compliance_reports(
if all_uploaded:
try:
rmtree(Path(out_dir), ignore_errors=True)
- logger.info(f"Cleaned up temporary files at {out_dir}")
+ logger.info("Cleaned up temporary files at %s", out_dir)
except Exception as e:
- logger.error(f"Error deleting output files: {e}")
+ logger.error("Error deleting output files: %s", e)
- logger.info(f"Compliance reports generation completed. Results: {results}")
+ logger.info("Compliance reports generation completed. Results: %s", results)
return results
@@ -3813,75 +483,24 @@ def generate_compliance_reports_job(
generate_nis2: bool = True,
) -> dict[str, dict[str, bool | str]]:
"""
- Job function to generate ThreatScore, ENS, and/or NIS2 compliance reports with optimized database queries.
-
- This function efficiently generates compliance reports by:
- - Fetching the provider object once (shared across all reports)
- - Aggregating requirement statistics once (shared across all reports)
- - Sharing findings cache between reports to avoid duplicate queries
- - Reducing total database queries by 50-70% compared to generating reports separately
-
- Use this job when you need to generate compliance reports for a scan.
+ Celery task wrapper for generate_compliance_reports.
Args:
- tenant_id (str): The tenant ID for Row-Level Security context.
- scan_id (str): The ID of the scan to generate reports for.
- provider_id (str): The ID of the provider used in the scan.
- generate_threatscore (bool): Whether to generate ThreatScore report. Defaults to True.
- generate_ens (bool): Whether to generate ENS report. Defaults to True.
- generate_nis2 (bool): Whether to generate NIS2 report. Defaults to True.
+ tenant_id: The tenant ID for Row-Level Security context.
+ scan_id: The ID of the scan to generate reports for.
+ provider_id: The ID of the provider used in the scan.
+ generate_threatscore: Whether to generate ThreatScore report.
+ generate_ens: Whether to generate ENS report.
+ generate_nis2: Whether to generate NIS2 report.
Returns:
- dict[str, dict[str, bool | str]]: Dictionary with results for each report:
- {
- 'threatscore': {'upload': bool, 'path': str, 'error': str (optional)},
- 'ens': {'upload': bool, 'path': str, 'error': str (optional)},
- 'nis2': {'upload': bool, 'path': str, 'error': str (optional)}
- }
-
- Example:
- >>> results = generate_compliance_reports_job(
- ... tenant_id="tenant-123",
- ... scan_id="scan-456",
- ... provider_id="provider-789"
- ... )
- >>> if results['threatscore']['upload']:
- ... print(f"ThreatScore uploaded to {results['threatscore']['path']}")
- >>> if results['ens']['upload']:
- ... print(f"ENS uploaded to {results['ens']['path']}")
- >>> if results['nis2']['upload']:
- ... print(f"NIS2 uploaded to {results['nis2']['path']}")
+ Dictionary with results for each report type.
"""
- logger.info(
- f"Starting optimized compliance reports job for scan {scan_id} "
- f"(ThreatScore: {generate_threatscore}, ENS: {generate_ens}, NIS2: {generate_nis2})"
+ return generate_compliance_reports(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ provider_id=provider_id,
+ generate_threatscore=generate_threatscore,
+ generate_ens=generate_ens,
+ generate_nis2=generate_nis2,
)
-
- try:
- results = generate_compliance_reports(
- tenant_id=tenant_id,
- scan_id=scan_id,
- provider_id=provider_id,
- generate_threatscore=generate_threatscore,
- generate_ens=generate_ens,
- generate_nis2=generate_nis2,
- only_failed_threatscore=True,
- min_risk_level_threatscore=4,
- include_manual_ens=True,
- include_manual_nis2=False,
- only_failed_nis2=True,
- )
- logger.info("Optimized compliance reports job completed successfully")
- return results
-
- except Exception as e:
- logger.error(f"Error in optimized compliance reports job: {e}")
- error_result = {"upload": False, "path": "", "error": str(e)}
- results = {}
- if generate_threatscore:
- results["threatscore"] = error_result.copy()
- if generate_ens:
- results["ens"] = error_result.copy()
- if generate_nis2:
- results["nis2"] = error_result.copy()
- return results
diff --git a/api/src/backend/tasks/jobs/reports/__init__.py b/api/src/backend/tasks/jobs/reports/__init__.py
new file mode 100644
index 0000000000..60602b93ab
--- /dev/null
+++ b/api/src/backend/tasks/jobs/reports/__init__.py
@@ -0,0 +1,186 @@
+# Base classes and data structures
+from .base import (
+ BaseComplianceReportGenerator,
+ ComplianceData,
+ RequirementData,
+ create_pdf_styles,
+ get_requirement_metadata,
+)
+
+# Chart functions
+from .charts import (
+ create_horizontal_bar_chart,
+ create_pie_chart,
+ create_radar_chart,
+ create_stacked_bar_chart,
+ create_vertical_bar_chart,
+ get_chart_color_for_percentage,
+)
+
+# Reusable components
+# Reusable components: Color helpers, Badge components, Risk component,
+# Table components, Section components
+from .components import (
+ ColumnConfig,
+ create_badge,
+ create_data_table,
+ create_findings_table,
+ create_info_table,
+ create_multi_badge_row,
+ create_risk_component,
+ create_section_header,
+ create_status_badge,
+ create_summary_table,
+ get_color_for_compliance,
+ get_color_for_risk_level,
+ get_color_for_weight,
+ get_status_color,
+)
+
+# Framework configuration: Main configuration, Color constants, ENS colors,
+# NIS2 colors, Chart colors, ENS constants, Section constants, Layout constants
+from .config import (
+ CHART_COLOR_BLUE,
+ CHART_COLOR_GREEN_1,
+ CHART_COLOR_GREEN_2,
+ CHART_COLOR_ORANGE,
+ CHART_COLOR_RED,
+ CHART_COLOR_YELLOW,
+ COL_WIDTH_LARGE,
+ COL_WIDTH_MEDIUM,
+ COL_WIDTH_SMALL,
+ COL_WIDTH_XLARGE,
+ COL_WIDTH_XXLARGE,
+ COLOR_BG_BLUE,
+ COLOR_BG_LIGHT_BLUE,
+ COLOR_BLUE,
+ COLOR_DARK_GRAY,
+ COLOR_ENS_ALTO,
+ COLOR_ENS_BAJO,
+ COLOR_ENS_MEDIO,
+ COLOR_ENS_OPCIONAL,
+ COLOR_GRAY,
+ COLOR_HIGH_RISK,
+ COLOR_LIGHT_BLUE,
+ COLOR_LIGHT_GRAY,
+ COLOR_LIGHTER_BLUE,
+ COLOR_LOW_RISK,
+ COLOR_MEDIUM_RISK,
+ COLOR_NIS2_PRIMARY,
+ COLOR_NIS2_SECONDARY,
+ COLOR_PROWLER_DARK_GREEN,
+ COLOR_SAFE,
+ COLOR_WHITE,
+ DIMENSION_KEYS,
+ DIMENSION_MAPPING,
+ DIMENSION_NAMES,
+ ENS_NIVEL_ORDER,
+ ENS_TIPO_ORDER,
+ FRAMEWORK_REGISTRY,
+ NIS2_SECTION_TITLES,
+ NIS2_SECTIONS,
+ PADDING_LARGE,
+ PADDING_MEDIUM,
+ PADDING_SMALL,
+ PADDING_XLARGE,
+ THREATSCORE_SECTIONS,
+ TIPO_ICONS,
+ FrameworkConfig,
+ get_framework_config,
+)
+
+# Framework-specific generators
+from .ens import ENSReportGenerator
+from .nis2 import NIS2ReportGenerator
+from .threatscore import ThreatScoreReportGenerator
+
+__all__ = [
+ # Base classes
+ "BaseComplianceReportGenerator",
+ "ComplianceData",
+ "RequirementData",
+ "create_pdf_styles",
+ "get_requirement_metadata",
+ # Framework-specific generators
+ "ThreatScoreReportGenerator",
+ "ENSReportGenerator",
+ "NIS2ReportGenerator",
+ # Configuration
+ "FrameworkConfig",
+ "FRAMEWORK_REGISTRY",
+ "get_framework_config",
+ # Color constants
+ "COLOR_BLUE",
+ "COLOR_LIGHT_BLUE",
+ "COLOR_LIGHTER_BLUE",
+ "COLOR_BG_BLUE",
+ "COLOR_BG_LIGHT_BLUE",
+ "COLOR_GRAY",
+ "COLOR_LIGHT_GRAY",
+ "COLOR_DARK_GRAY",
+ "COLOR_WHITE",
+ "COLOR_HIGH_RISK",
+ "COLOR_MEDIUM_RISK",
+ "COLOR_LOW_RISK",
+ "COLOR_SAFE",
+ "COLOR_PROWLER_DARK_GREEN",
+ "COLOR_ENS_ALTO",
+ "COLOR_ENS_MEDIO",
+ "COLOR_ENS_BAJO",
+ "COLOR_ENS_OPCIONAL",
+ "COLOR_NIS2_PRIMARY",
+ "COLOR_NIS2_SECONDARY",
+ "CHART_COLOR_BLUE",
+ "CHART_COLOR_GREEN_1",
+ "CHART_COLOR_GREEN_2",
+ "CHART_COLOR_YELLOW",
+ "CHART_COLOR_ORANGE",
+ "CHART_COLOR_RED",
+ # ENS constants
+ "DIMENSION_MAPPING",
+ "DIMENSION_NAMES",
+ "DIMENSION_KEYS",
+ "ENS_NIVEL_ORDER",
+ "ENS_TIPO_ORDER",
+ "TIPO_ICONS",
+ # Section constants
+ "THREATSCORE_SECTIONS",
+ "NIS2_SECTIONS",
+ "NIS2_SECTION_TITLES",
+ # Layout constants
+ "COL_WIDTH_SMALL",
+ "COL_WIDTH_MEDIUM",
+ "COL_WIDTH_LARGE",
+ "COL_WIDTH_XLARGE",
+ "COL_WIDTH_XXLARGE",
+ "PADDING_SMALL",
+ "PADDING_MEDIUM",
+ "PADDING_LARGE",
+ "PADDING_XLARGE",
+ # Color helpers
+ "get_color_for_risk_level",
+ "get_color_for_weight",
+ "get_color_for_compliance",
+ "get_status_color",
+ # Badge components
+ "create_badge",
+ "create_status_badge",
+ "create_multi_badge_row",
+ # Risk component
+ "create_risk_component",
+ # Table components
+ "create_info_table",
+ "create_data_table",
+ "create_findings_table",
+ "ColumnConfig",
+ # Section components
+ "create_section_header",
+ "create_summary_table",
+ # Chart functions
+ "get_chart_color_for_percentage",
+ "create_vertical_bar_chart",
+ "create_horizontal_bar_chart",
+ "create_radar_chart",
+ "create_pie_chart",
+ "create_stacked_bar_chart",
+]
diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py
new file mode 100644
index 0000000000..42776681fb
--- /dev/null
+++ b/api/src/backend/tasks/jobs/reports/base.py
@@ -0,0 +1,911 @@
+import gc
+import os
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Any
+
+from celery.utils.log import get_task_logger
+from reportlab.lib.enums import TA_CENTER
+from reportlab.lib.pagesizes import letter
+from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
+from reportlab.lib.units import inch
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.pdfgen import canvas
+from reportlab.platypus import Image, PageBreak, Paragraph, SimpleDocTemplate, Spacer
+from tasks.jobs.threatscore_utils import (
+ _aggregate_requirement_statistics_from_database,
+ _calculate_requirements_data_from_statistics,
+ _load_findings_for_requirement_checks,
+)
+
+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
+from prowler.lib.outputs.finding import Finding as FindingOutput
+
+from .components import (
+ ColumnConfig,
+ create_data_table,
+ create_info_table,
+ create_status_badge,
+)
+from .config import (
+ COLOR_BG_BLUE,
+ COLOR_BG_LIGHT_BLUE,
+ COLOR_BLUE,
+ COLOR_BORDER_GRAY,
+ COLOR_GRAY,
+ COLOR_LIGHT_BLUE,
+ COLOR_LIGHTER_BLUE,
+ COLOR_PROWLER_DARK_GREEN,
+ PADDING_LARGE,
+ PADDING_SMALL,
+ FrameworkConfig,
+)
+
+logger = get_task_logger(__name__)
+
+# Register fonts (done once at module load)
+_fonts_registered: bool = False
+
+
+def _register_fonts() -> None:
+ """Register custom fonts for PDF generation.
+
+ Uses a module-level flag to ensure fonts are only registered once,
+ avoiding duplicate registration errors from reportlab.
+ """
+ global _fonts_registered
+ if _fonts_registered:
+ return
+
+ fonts_dir = os.path.join(os.path.dirname(__file__), "../../assets/fonts")
+
+ pdfmetrics.registerFont(
+ TTFont(
+ "PlusJakartaSans",
+ os.path.join(fonts_dir, "PlusJakartaSans-Regular.ttf"),
+ )
+ )
+
+ pdfmetrics.registerFont(
+ TTFont(
+ "FiraCode",
+ os.path.join(fonts_dir, "FiraCode-Regular.ttf"),
+ )
+ )
+
+ _fonts_registered = True
+
+
+# =============================================================================
+# Data Classes
+# =============================================================================
+
+
+@dataclass
+class RequirementData:
+ """Data for a single compliance requirement.
+
+ Attributes:
+ id: Requirement identifier
+ description: Requirement description
+ status: Compliance status (PASS, FAIL, MANUAL)
+ passed_findings: Number of passed findings
+ failed_findings: Number of failed findings
+ total_findings: Total number of findings
+ checks: List of check IDs associated with this requirement
+ attributes: Framework-specific requirement attributes
+ """
+
+ id: str
+ description: str
+ status: str
+ passed_findings: int = 0
+ failed_findings: int = 0
+ total_findings: int = 0
+ checks: list[str] = field(default_factory=list)
+ attributes: Any = None
+
+
+@dataclass
+class ComplianceData:
+ """Aggregated compliance data for report generation.
+
+ This dataclass holds all the data needed to generate a compliance report,
+ including compliance framework metadata, requirements, and findings.
+
+ Attributes:
+ tenant_id: Tenant identifier
+ scan_id: Scan identifier
+ provider_id: Provider identifier
+ compliance_id: Compliance framework identifier
+ framework: Framework name (e.g., "CIS", "ENS")
+ name: Full compliance framework name
+ version: Framework version
+ description: Framework description
+ requirements: List of RequirementData objects
+ attributes_by_requirement_id: Mapping of requirement IDs to their attributes
+ findings_by_check_id: Mapping of check IDs to their findings
+ provider_obj: Provider model object
+ prowler_provider: Initialized Prowler provider
+ """
+
+ tenant_id: str
+ scan_id: str
+ provider_id: str
+ compliance_id: str
+ framework: str
+ name: str
+ version: str
+ description: str
+ requirements: list[RequirementData] = field(default_factory=list)
+ attributes_by_requirement_id: dict[str, dict] = field(default_factory=dict)
+ findings_by_check_id: dict[str, list[FindingOutput]] = field(default_factory=dict)
+ provider_obj: Provider | None = None
+ prowler_provider: Any = None
+
+
+def get_requirement_metadata(
+ requirement_id: str,
+ attributes_by_requirement_id: dict[str, dict],
+) -> Any | None:
+ """Get the first requirement metadata object from attributes.
+
+ This helper function extracts the requirement metadata (req_attributes)
+ from the attributes dictionary. It's a common pattern used across all
+ report generators.
+
+ Args:
+ requirement_id: The requirement ID to look up.
+ attributes_by_requirement_id: Mapping of requirement IDs to their attributes.
+
+ Returns:
+ The first requirement attribute object, or None if not found.
+
+ Example:
+ >>> meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ >>> if meta:
+ ... section = getattr(meta, "Section", "Unknown")
+ """
+ req_attrs = attributes_by_requirement_id.get(requirement_id, {})
+ meta_list = req_attrs.get("attributes", {}).get("req_attributes", [])
+ if meta_list:
+ return meta_list[0]
+ return None
+
+
+# =============================================================================
+# PDF Styles Cache
+# =============================================================================
+
+_PDF_STYLES_CACHE: dict[str, ParagraphStyle] | None = None
+
+
+def create_pdf_styles() -> dict[str, ParagraphStyle]:
+ """Create and return PDF paragraph styles used throughout the report.
+
+ Styles are cached on first call to improve performance.
+
+ Returns:
+ Dictionary containing the following styles:
+ - 'title': Title style with prowler green color
+ - 'h1': Heading 1 style with blue color and background
+ - 'h2': Heading 2 style with light blue color
+ - 'h3': Heading 3 style for sub-headings
+ - 'normal': Normal text style with left indent
+ - 'normal_center': Normal text style without indent
+ """
+ global _PDF_STYLES_CACHE
+
+ if _PDF_STYLES_CACHE is not None:
+ return _PDF_STYLES_CACHE
+
+ _register_fonts()
+ styles = getSampleStyleSheet()
+
+ title_style = ParagraphStyle(
+ "CustomTitle",
+ parent=styles["Title"],
+ fontSize=24,
+ textColor=COLOR_PROWLER_DARK_GREEN,
+ spaceAfter=20,
+ fontName="PlusJakartaSans",
+ alignment=TA_CENTER,
+ )
+
+ h1 = ParagraphStyle(
+ "CustomH1",
+ parent=styles["Heading1"],
+ fontSize=18,
+ textColor=COLOR_BLUE,
+ spaceBefore=20,
+ spaceAfter=12,
+ fontName="PlusJakartaSans",
+ leftIndent=0,
+ borderWidth=2,
+ borderColor=COLOR_BLUE,
+ borderPadding=PADDING_LARGE,
+ backColor=COLOR_BG_BLUE,
+ )
+
+ h2 = ParagraphStyle(
+ "CustomH2",
+ parent=styles["Heading2"],
+ fontSize=14,
+ textColor=COLOR_LIGHT_BLUE,
+ spaceBefore=15,
+ spaceAfter=8,
+ fontName="PlusJakartaSans",
+ leftIndent=10,
+ borderWidth=1,
+ borderColor=COLOR_BORDER_GRAY,
+ borderPadding=5,
+ backColor=COLOR_BG_LIGHT_BLUE,
+ )
+
+ h3 = ParagraphStyle(
+ "CustomH3",
+ parent=styles["Heading3"],
+ fontSize=12,
+ textColor=COLOR_LIGHTER_BLUE,
+ spaceBefore=10,
+ spaceAfter=6,
+ fontName="PlusJakartaSans",
+ leftIndent=20,
+ )
+
+ normal = ParagraphStyle(
+ "CustomNormal",
+ parent=styles["Normal"],
+ fontSize=10,
+ textColor=COLOR_GRAY,
+ spaceBefore=PADDING_SMALL,
+ spaceAfter=PADDING_SMALL,
+ leftIndent=30,
+ fontName="PlusJakartaSans",
+ )
+
+ normal_center = ParagraphStyle(
+ "CustomNormalCenter",
+ parent=styles["Normal"],
+ fontSize=10,
+ textColor=COLOR_GRAY,
+ fontName="PlusJakartaSans",
+ )
+
+ _PDF_STYLES_CACHE = {
+ "title": title_style,
+ "h1": h1,
+ "h2": h2,
+ "h3": h3,
+ "normal": normal,
+ "normal_center": normal_center,
+ }
+
+ return _PDF_STYLES_CACHE
+
+
+# =============================================================================
+# Base Report Generator
+# =============================================================================
+
+
+class BaseComplianceReportGenerator(ABC):
+ """Abstract base class for compliance PDF report generators.
+
+ This class implements the Template Method pattern, providing a common
+ structure for all compliance reports while allowing subclasses to
+ customize specific sections.
+
+ Subclasses must implement:
+ - create_executive_summary()
+ - create_charts_section()
+ - create_requirements_index()
+
+ Optionally, subclasses can override:
+ - create_cover_page()
+ - create_detailed_findings()
+ - get_footer_text()
+ """
+
+ def __init__(self, config: FrameworkConfig):
+ """Initialize the report generator.
+
+ Args:
+ config: Framework configuration
+ """
+ self.config = config
+ self.styles = create_pdf_styles()
+
+ # =========================================================================
+ # Template Method
+ # =========================================================================
+
+ def generate(
+ self,
+ tenant_id: str,
+ scan_id: str,
+ compliance_id: str,
+ output_path: str,
+ provider_id: str,
+ provider_obj: Provider | None = None,
+ requirement_statistics: dict[str, dict[str, int]] | None = None,
+ findings_cache: dict[str, list[FindingOutput]] | None = None,
+ **kwargs,
+ ) -> None:
+ """Generate the PDF compliance report.
+
+ This is the template method that orchestrates the report generation.
+ It calls abstract methods that subclasses must implement.
+
+ Args:
+ tenant_id: Tenant identifier for RLS context
+ scan_id: Scan identifier
+ compliance_id: Compliance framework identifier
+ output_path: Path where the PDF will be saved
+ provider_id: Provider identifier
+ provider_obj: Optional pre-fetched Provider object
+ requirement_statistics: Optional pre-aggregated statistics
+ findings_cache: Optional pre-loaded findings cache
+ **kwargs: Additional framework-specific arguments
+ """
+ logger.info(
+ "Generating %s report for scan %s", self.config.display_name, scan_id
+ )
+
+ try:
+ # 1. Load compliance data
+ data = self._load_compliance_data(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ compliance_id=compliance_id,
+ provider_id=provider_id,
+ provider_obj=provider_obj,
+ requirement_statistics=requirement_statistics,
+ findings_cache=findings_cache,
+ )
+
+ # 2. Create PDF document
+ doc = self._create_document(output_path, data)
+
+ # 3. Build report elements incrementally to manage memory
+ # We collect garbage after heavy sections to prevent OOM on large reports
+ elements = []
+
+ # Cover page (lightweight)
+ elements.extend(self.create_cover_page(data))
+ elements.append(PageBreak())
+
+ # Executive summary (framework-specific)
+ elements.extend(self.create_executive_summary(data))
+
+ # Body sections (charts + requirements index)
+ # Override _build_body_sections() in subclasses to change section order
+ elements.extend(self._build_body_sections(data))
+
+ # Detailed findings - heaviest section, loads findings on-demand
+ logger.info("Building detailed findings section...")
+ elements.extend(self.create_detailed_findings(data, **kwargs))
+ gc.collect() # Free findings data after processing
+
+ # 4. Build the PDF
+ logger.info("Building PDF document with %d elements...", len(elements))
+ self._build_pdf(doc, elements, data)
+
+ # Final cleanup
+ del elements
+ gc.collect()
+
+ logger.info("Successfully generated report at %s", output_path)
+
+ except Exception as e:
+ import traceback
+
+ tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown"
+ logger.error("Error generating report, line %s -- %s", tb_lineno, e)
+ logger.error("Full traceback:\n%s", traceback.format_exc())
+ raise
+
+ def _build_body_sections(self, data: ComplianceData) -> list:
+ """Build the body sections between executive summary and detailed findings.
+
+ Override in subclasses to change section order.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ # Charts section (framework-specific) - heavy on memory due to matplotlib
+ elements.extend(self.create_charts_section(data))
+ elements.append(PageBreak())
+ gc.collect() # Free matplotlib resources
+
+ # Requirements index (framework-specific)
+ elements.extend(self.create_requirements_index(data))
+ elements.append(PageBreak())
+
+ return elements
+
+ # =========================================================================
+ # Abstract Methods (must be implemented by subclasses)
+ # =========================================================================
+
+ @abstractmethod
+ def create_executive_summary(self, data: ComplianceData) -> list:
+ """Create the executive summary section.
+
+ This section typically includes:
+ - Overall compliance score/metrics
+ - High-level statistics
+ - Critical findings summary
+
+ Args:
+ data: Aggregated compliance data
+
+ Returns:
+ List of ReportLab elements
+ """
+
+ @abstractmethod
+ def create_charts_section(self, data: ComplianceData) -> list:
+ """Create the charts and visualizations section.
+
+ This section typically includes:
+ - Compliance score charts by section
+ - Distribution charts
+ - Trend visualizations
+
+ Args:
+ data: Aggregated compliance data
+
+ Returns:
+ List of ReportLab elements
+ """
+
+ @abstractmethod
+ def create_requirements_index(self, data: ComplianceData) -> list:
+ """Create the requirements index/table of contents.
+
+ This section typically includes:
+ - Hierarchical list of requirements
+ - Status indicators
+ - Section groupings
+
+ Args:
+ data: Aggregated compliance data
+
+ Returns:
+ List of ReportLab elements
+ """
+
+ # =========================================================================
+ # Common Methods (can be overridden by subclasses)
+ # =========================================================================
+
+ def create_cover_page(self, data: ComplianceData) -> list:
+ """Create the report cover page.
+
+ Args:
+ data: Aggregated compliance data
+
+ Returns:
+ List of ReportLab elements
+ """
+ elements = []
+
+ # Prowler logo
+ logo_path = os.path.join(
+ os.path.dirname(__file__), "../../assets/img/prowler_logo.png"
+ )
+ if os.path.exists(logo_path):
+ logo = Image(logo_path, width=5 * inch, height=1 * inch)
+ elements.append(logo)
+
+ elements.append(Spacer(1, 0.5 * inch))
+
+ # Title
+ title_text = f"{self.config.display_name} Report"
+ elements.append(Paragraph(title_text, self.styles["title"]))
+ elements.append(Spacer(1, 0.5 * inch))
+
+ # Compliance info table
+ info_rows = self._build_info_rows(data, language=self.config.language)
+
+ info_table = create_info_table(
+ rows=info_rows,
+ label_width=2 * inch,
+ value_width=4 * inch,
+ normal_style=self.styles["normal_center"],
+ )
+ elements.append(info_table)
+
+ return elements
+
+ def _build_info_rows(
+ self, data: ComplianceData, language: str = "en"
+ ) -> list[tuple[str, str]]:
+ """Build the standard info rows for the cover page table.
+
+ This helper method creates the common metadata rows used in all
+ report cover pages. Subclasses can use this to maintain consistency
+ while customizing other aspects of the cover page.
+
+ Args:
+ data: Aggregated compliance data.
+ language: Language for labels ("en" or "es").
+
+ Returns:
+ List of (label, value) tuples for the info table.
+ """
+ # Labels based on language
+ labels = {
+ "en": {
+ "framework": "Framework:",
+ "id": "ID:",
+ "name": "Name:",
+ "version": "Version:",
+ "provider": "Provider:",
+ "account_id": "Account ID:",
+ "alias": "Alias:",
+ "scan_id": "Scan ID:",
+ "description": "Description:",
+ },
+ "es": {
+ "framework": "Framework:",
+ "id": "ID:",
+ "name": "Nombre:",
+ "version": "VersiΓ³n:",
+ "provider": "Proveedor:",
+ "account_id": "Account ID:",
+ "alias": "Alias:",
+ "scan_id": "Scan ID:",
+ "description": "DescripciΓ³n:",
+ },
+ }
+ lang_labels = labels.get(language, labels["en"])
+
+ info_rows = [
+ (lang_labels["framework"], data.framework),
+ (lang_labels["id"], data.compliance_id),
+ (lang_labels["name"], data.name),
+ (lang_labels["version"], data.version),
+ ]
+
+ # Add provider info if available
+ if data.provider_obj:
+ info_rows.append(
+ (lang_labels["provider"], data.provider_obj.provider.upper())
+ )
+ info_rows.append(
+ (lang_labels["account_id"], data.provider_obj.uid or "N/A")
+ )
+ info_rows.append((lang_labels["alias"], data.provider_obj.alias or "N/A"))
+
+ info_rows.append((lang_labels["scan_id"], data.scan_id))
+
+ if data.description:
+ info_rows.append((lang_labels["description"], data.description))
+
+ return info_rows
+
+ def create_detailed_findings(self, data: ComplianceData, **kwargs) -> list:
+ """Create the detailed findings section.
+
+ This default implementation creates a requirement-by-requirement
+ breakdown with findings tables. Subclasses can override for
+ framework-specific presentation.
+
+ This method implements on-demand loading of findings using the shared
+ findings cache to minimize database queries and memory usage.
+
+ Args:
+ data: Aggregated compliance data
+ **kwargs: Framework-specific options (e.g., only_failed)
+
+ Returns:
+ List of ReportLab elements
+ """
+ elements = []
+ only_failed = kwargs.get("only_failed", True)
+ include_manual = kwargs.get("include_manual", False)
+
+ # Filter requirements if needed
+ requirements = data.requirements
+ if only_failed:
+ # Include FAIL requirements, and optionally MANUAL if include_manual is True
+ if include_manual:
+ requirements = [
+ r
+ for r in requirements
+ if r.status in (StatusChoices.FAIL, StatusChoices.MANUAL)
+ ]
+ else:
+ requirements = [
+ r for r in requirements if r.status == StatusChoices.FAIL
+ ]
+
+ # Collect all check IDs for requirements that will be displayed
+ # This allows us to load only the findings we actually need (memory optimization)
+ check_ids_to_load = []
+ for req in requirements:
+ check_ids_to_load.extend(req.checks)
+
+ # Load findings on-demand only for the checks that will be displayed
+ # Uses the shared findings cache to avoid duplicate queries across reports
+ logger.info("Loading findings on-demand for %d requirements", len(requirements))
+ findings_by_check_id = _load_findings_for_requirement_checks(
+ data.tenant_id,
+ data.scan_id,
+ check_ids_to_load,
+ data.prowler_provider,
+ data.findings_by_check_id, # Pass the cache to update it
+ )
+
+ for req in requirements:
+ # Requirement header
+ elements.append(
+ Paragraph(
+ f"{req.id}: {req.description}",
+ self.styles["h1"],
+ )
+ )
+
+ # Status badge
+ elements.append(create_status_badge(req.status))
+ elements.append(Spacer(1, 0.1 * inch))
+
+ # Findings for this requirement
+ for check_id in req.checks:
+ elements.append(Paragraph(f"Check: {check_id}", self.styles["h2"]))
+
+ findings = findings_by_check_id.get(check_id, [])
+ if not findings:
+ elements.append(
+ Paragraph(
+ "- No information for this finding currently",
+ self.styles["normal"],
+ )
+ )
+ else:
+ # Create findings table
+ findings_table = self._create_findings_table(findings)
+ elements.append(findings_table)
+
+ elements.append(Spacer(1, 0.1 * inch))
+
+ elements.append(PageBreak())
+
+ return elements
+
+ def get_footer_text(self, page_num: int) -> tuple[str, str]:
+ """Get footer text for a page.
+
+ Args:
+ page_num: Current page number
+
+ Returns:
+ Tuple of (left_text, right_text) for the footer
+ """
+ if self.config.language == "es":
+ page_text = f"PΓ‘gina {page_num}"
+ else:
+ page_text = f"Page {page_num}"
+
+ return page_text, "Powered by Prowler"
+
+ # =========================================================================
+ # Private Helper Methods
+ # =========================================================================
+
+ def _load_compliance_data(
+ self,
+ tenant_id: str,
+ scan_id: str,
+ compliance_id: str,
+ provider_id: str,
+ provider_obj: Provider | None,
+ requirement_statistics: dict | None,
+ findings_cache: dict | None,
+ ) -> ComplianceData:
+ """Load and aggregate compliance data from the database.
+
+ Args:
+ tenant_id: Tenant identifier
+ scan_id: Scan identifier
+ compliance_id: Compliance framework identifier
+ provider_id: Provider identifier
+ provider_obj: Optional pre-fetched Provider
+ requirement_statistics: Optional pre-aggregated statistics
+ findings_cache: Optional pre-loaded findings
+
+ Returns:
+ Aggregated ComplianceData object
+ """
+ with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
+ # Load provider
+ if provider_obj is None:
+ provider_obj = Provider.objects.get(id=provider_id)
+
+ prowler_provider = initialize_prowler_provider(provider_obj)
+ provider_type = provider_obj.provider
+
+ # 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}")
+
+ framework = getattr(compliance_obj, "Framework", "N/A")
+ name = getattr(compliance_obj, "Name", "N/A")
+ version = getattr(compliance_obj, "Version", "N/A")
+ description = getattr(compliance_obj, "Description", "")
+
+ # Aggregate requirement statistics
+ if requirement_statistics is None:
+ logger.info("Aggregating requirement statistics for scan %s", scan_id)
+ requirement_statistics = _aggregate_requirement_statistics_from_database(
+ tenant_id, scan_id
+ )
+ else:
+ logger.info("Reusing pre-aggregated statistics for scan %s", scan_id)
+
+ # Calculate requirements data
+ attributes_by_requirement_id, requirements_list = (
+ _calculate_requirements_data_from_statistics(
+ compliance_obj, requirement_statistics
+ )
+ )
+
+ # Convert to RequirementData objects
+ requirements = []
+ for req_dict in requirements_list:
+ req = RequirementData(
+ id=req_dict["id"],
+ description=req_dict["attributes"].get("description", ""),
+ status=req_dict["attributes"].get("status", StatusChoices.MANUAL),
+ passed_findings=req_dict["attributes"].get("passed_findings", 0),
+ failed_findings=req_dict["attributes"].get("failed_findings", 0),
+ total_findings=req_dict["attributes"].get("total_findings", 0),
+ checks=attributes_by_requirement_id.get(req_dict["id"], {})
+ .get("attributes", {})
+ .get("checks", []),
+ )
+ requirements.append(req)
+
+ return ComplianceData(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ provider_id=provider_id,
+ compliance_id=compliance_id,
+ framework=framework,
+ name=name,
+ version=version,
+ description=description,
+ requirements=requirements,
+ attributes_by_requirement_id=attributes_by_requirement_id,
+ findings_by_check_id=findings_cache if findings_cache is not None else {},
+ provider_obj=provider_obj,
+ prowler_provider=prowler_provider,
+ )
+
+ def _create_document(
+ self, output_path: str, data: ComplianceData
+ ) -> SimpleDocTemplate:
+ """Create the PDF document template.
+
+ Args:
+ output_path: Path for the output PDF
+ data: Compliance data for metadata
+
+ Returns:
+ Configured SimpleDocTemplate
+ """
+ return SimpleDocTemplate(
+ output_path,
+ pagesize=letter,
+ title=f"{self.config.display_name} Report - {data.framework}",
+ author="Prowler",
+ subject=f"Compliance Report for {data.framework}",
+ creator="Prowler Engineering Team",
+ keywords=f"compliance,{data.framework},security,framework,prowler",
+ )
+
+ def _build_pdf(
+ self,
+ doc: SimpleDocTemplate,
+ elements: list,
+ data: ComplianceData,
+ ) -> None:
+ """Build the final PDF with footers.
+
+ Args:
+ doc: Document template
+ elements: List of ReportLab elements
+ data: Compliance data
+ """
+
+ def add_footer(
+ canvas_obj: canvas.Canvas,
+ doc_template: SimpleDocTemplate,
+ ) -> None:
+ canvas_obj.saveState()
+ width, _ = doc_template.pagesize
+ left_text, right_text = self.get_footer_text(doc_template.page)
+
+ canvas_obj.setFont("PlusJakartaSans", 9)
+ canvas_obj.setFillColorRGB(0.4, 0.4, 0.4)
+ canvas_obj.drawString(30, 20, left_text)
+
+ text_width = canvas_obj.stringWidth(right_text, "PlusJakartaSans", 9)
+ canvas_obj.drawString(width - text_width - 30, 20, right_text)
+ canvas_obj.restoreState()
+
+ doc.build(
+ elements,
+ onFirstPage=add_footer,
+ onLaterPages=add_footer,
+ )
+
+ def _create_findings_table(self, findings: list[FindingOutput]) -> Any:
+ """Create a findings table.
+
+ Args:
+ findings: List of finding objects
+
+ Returns:
+ ReportLab Table element
+ """
+
+ def get_finding_title(f):
+ metadata = getattr(f, "metadata", None)
+ if metadata:
+ return getattr(metadata, "CheckTitle", getattr(f, "check_id", ""))
+ return getattr(f, "check_id", "")
+
+ def get_resource_name(f):
+ name = getattr(f, "resource_name", "")
+ if not name:
+ name = getattr(f, "resource_uid", "")
+ return name
+
+ def get_severity(f):
+ metadata = getattr(f, "metadata", None)
+ if metadata:
+ return getattr(metadata, "Severity", "").capitalize()
+ return ""
+
+ # Convert findings to dicts for the table
+ data = []
+ for f in findings:
+ item = {
+ "title": get_finding_title(f),
+ "resource_name": get_resource_name(f),
+ "severity": get_severity(f),
+ "status": getattr(f, "status", "").upper(),
+ "region": getattr(f, "region", "global"),
+ }
+ data.append(item)
+
+ columns = [
+ ColumnConfig("Finding", 2.5 * inch, "title"),
+ ColumnConfig("Resource", 3 * inch, "resource_name"),
+ ColumnConfig("Severity", 0.9 * inch, "severity"),
+ ColumnConfig("Status", 0.9 * inch, "status"),
+ ColumnConfig("Region", 0.9 * inch, "region"),
+ ]
+
+ return create_data_table(
+ data=data,
+ columns=columns,
+ header_color=self.config.primary_color,
+ normal_style=self.styles["normal_center"],
+ )
diff --git a/api/src/backend/tasks/jobs/reports/charts.py b/api/src/backend/tasks/jobs/reports/charts.py
new file mode 100644
index 0000000000..0f0338acab
--- /dev/null
+++ b/api/src/backend/tasks/jobs/reports/charts.py
@@ -0,0 +1,404 @@
+import gc
+import io
+import math
+from typing import Callable
+
+import matplotlib
+
+# Use non-interactive Agg backend for memory efficiency in server environments
+# This MUST be set before importing pyplot
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt # noqa: E402
+
+from .config import ( # noqa: E402
+ CHART_COLOR_BLUE,
+ CHART_COLOR_GREEN_1,
+ CHART_COLOR_GREEN_2,
+ CHART_COLOR_ORANGE,
+ CHART_COLOR_RED,
+ CHART_COLOR_YELLOW,
+ CHART_DPI_DEFAULT,
+)
+
+# Use centralized DPI setting from config
+DEFAULT_CHART_DPI = CHART_DPI_DEFAULT
+
+
+def get_chart_color_for_percentage(percentage: float) -> str:
+ """Get chart color string based on percentage.
+
+ Args:
+ percentage: Value between 0 and 100
+
+ Returns:
+ Hex color string for matplotlib
+ """
+ if percentage >= 80:
+ return CHART_COLOR_GREEN_1
+ if percentage >= 60:
+ return CHART_COLOR_GREEN_2
+ if percentage >= 40:
+ return CHART_COLOR_YELLOW
+ if percentage >= 20:
+ return CHART_COLOR_ORANGE
+ return CHART_COLOR_RED
+
+
+def create_vertical_bar_chart(
+ labels: list[str],
+ values: list[float],
+ ylabel: str = "Compliance Score (%)",
+ xlabel: str = "Section",
+ title: str | None = None,
+ color_func: Callable[[float], str] | None = None,
+ colors: list[str] | None = None,
+ figsize: tuple[int, int] = (10, 6),
+ dpi: int = DEFAULT_CHART_DPI,
+ y_limit: tuple[float, float] = (0, 100),
+ show_labels: bool = True,
+ rotation: int = 45,
+) -> io.BytesIO:
+ """Create a vertical bar chart.
+
+ Args:
+ labels: X-axis labels
+ values: Bar heights (numeric values)
+ ylabel: Y-axis label
+ xlabel: X-axis label
+ title: Optional chart title
+ color_func: Function to determine bar color based on value
+ colors: Explicit list of colors (overrides color_func)
+ figsize: Figure size (width, height) in inches
+ dpi: Resolution for output image
+ y_limit: Y-axis limits (min, max)
+ show_labels: Whether to show value labels on bars
+ rotation: X-axis label rotation angle
+
+ Returns:
+ BytesIO buffer containing the PNG image
+ """
+ if color_func is None:
+ color_func = get_chart_color_for_percentage
+
+ fig, ax = plt.subplots(figsize=figsize)
+
+ # Determine colors
+ if colors is None:
+ colors_list = [color_func(v) for v in values]
+ else:
+ colors_list = colors
+
+ bars = ax.bar(labels, values, color=colors_list)
+
+ ax.set_ylabel(ylabel, fontsize=12)
+ ax.set_xlabel(xlabel, fontsize=12)
+ ax.set_ylim(*y_limit)
+
+ if title:
+ ax.set_title(title, fontsize=14, fontweight="bold")
+
+ # Add value labels on bars
+ if show_labels:
+ for bar_item, value in zip(bars, values):
+ height = bar_item.get_height()
+ ax.text(
+ bar_item.get_x() + bar_item.get_width() / 2.0,
+ height + 1,
+ f"{value:.1f}%",
+ ha="center",
+ va="bottom",
+ fontweight="bold",
+ )
+
+ plt.xticks(rotation=rotation, ha="right")
+ ax.grid(True, alpha=0.3, axis="y")
+ plt.tight_layout()
+
+ buffer = io.BytesIO()
+ try:
+ fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
+ buffer.seek(0)
+ finally:
+ plt.close(fig)
+ gc.collect() # Force garbage collection after heavy matplotlib operation
+
+ return buffer
+
+
+def create_horizontal_bar_chart(
+ labels: list[str],
+ values: list[float],
+ xlabel: str = "Compliance (%)",
+ title: str | None = None,
+ color_func: Callable[[float], str] | None = None,
+ colors: list[str] | None = None,
+ figsize: tuple[int, int] | None = None,
+ dpi: int = DEFAULT_CHART_DPI,
+ x_limit: tuple[float, float] = (0, 100),
+ show_labels: bool = True,
+ label_fontsize: int = 16,
+) -> io.BytesIO:
+ """Create a horizontal bar chart.
+
+ Args:
+ labels: Y-axis labels (bar names)
+ values: Bar widths (numeric values)
+ xlabel: X-axis label
+ title: Optional chart title
+ color_func: Function to determine bar color based on value
+ colors: Explicit list of colors (overrides color_func)
+ figsize: Figure size (auto-calculated if None based on label count)
+ dpi: Resolution for output image
+ x_limit: X-axis limits (min, max)
+ show_labels: Whether to show value labels on bars
+ label_fontsize: Font size for y-axis labels
+
+ Returns:
+ BytesIO buffer containing the PNG image
+ """
+ if color_func is None:
+ color_func = get_chart_color_for_percentage
+
+ # Auto-calculate figure size based on number of items
+ if figsize is None:
+ figsize = (10, max(6, int(len(labels) * 0.4)))
+
+ fig, ax = plt.subplots(figsize=figsize)
+
+ # Determine colors
+ if colors is None:
+ colors_list = [color_func(v) for v in values]
+ else:
+ colors_list = colors
+
+ y_pos = range(len(labels))
+ bars = ax.barh(y_pos, values, color=colors_list)
+
+ ax.set_yticks(y_pos)
+ ax.set_yticklabels(labels, fontsize=label_fontsize)
+ ax.set_xlabel(xlabel, fontsize=14)
+ ax.set_xlim(*x_limit)
+
+ if title:
+ ax.set_title(title, fontsize=14, fontweight="bold")
+
+ # Add value labels
+ if show_labels:
+ for bar_item, value in zip(bars, values):
+ width = bar_item.get_width()
+ ax.text(
+ width + 1,
+ bar_item.get_y() + bar_item.get_height() / 2.0,
+ f"{value:.1f}%",
+ ha="left",
+ va="center",
+ fontweight="bold",
+ fontsize=10,
+ )
+
+ ax.grid(True, alpha=0.3, axis="x")
+ plt.tight_layout()
+
+ buffer = io.BytesIO()
+ try:
+ fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
+ buffer.seek(0)
+ finally:
+ plt.close(fig)
+ gc.collect() # Force garbage collection after heavy matplotlib operation
+
+ return buffer
+
+
+def create_radar_chart(
+ labels: list[str],
+ values: list[float],
+ color: str = CHART_COLOR_BLUE,
+ fill_alpha: float = 0.25,
+ figsize: tuple[int, int] = (8, 8),
+ dpi: int = DEFAULT_CHART_DPI,
+ y_limit: tuple[float, float] = (0, 100),
+ y_ticks: list[int] | None = None,
+ label_fontsize: int = 14,
+ title: str | None = None,
+) -> io.BytesIO:
+ """Create a radar/spider chart.
+
+ Args:
+ labels: Category names around the chart
+ values: Values for each category (should have same length as labels)
+ color: Line and fill color
+ fill_alpha: Transparency of the fill (0-1)
+ figsize: Figure size (width, height) in inches
+ dpi: Resolution for output image
+ y_limit: Radial axis limits (min, max)
+ y_ticks: Custom tick values for radial axis
+ label_fontsize: Font size for category labels
+ title: Optional chart title
+
+ Returns:
+ BytesIO buffer containing the PNG image
+ """
+ num_vars = len(labels)
+ angles = [n / float(num_vars) * 2 * math.pi for n in range(num_vars)]
+
+ # Close the polygon
+ values_closed = list(values) + [values[0]]
+ angles_closed = angles + [angles[0]]
+
+ fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": "polar"})
+
+ ax.plot(angles_closed, values_closed, "o-", linewidth=2, color=color)
+ ax.fill(angles_closed, values_closed, alpha=fill_alpha, color=color)
+
+ ax.set_xticks(angles)
+ ax.set_xticklabels(labels, fontsize=label_fontsize)
+ ax.set_ylim(*y_limit)
+
+ if y_ticks is None:
+ y_ticks = [20, 40, 60, 80, 100]
+ ax.set_yticks(y_ticks)
+ ax.set_yticklabels([f"{t}%" for t in y_ticks], fontsize=12)
+
+ ax.grid(True, alpha=0.3)
+
+ if title:
+ ax.set_title(title, fontsize=14, fontweight="bold", y=1.08)
+
+ plt.tight_layout()
+
+ buffer = io.BytesIO()
+ try:
+ fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
+ buffer.seek(0)
+ finally:
+ plt.close(fig)
+ gc.collect() # Force garbage collection after heavy matplotlib operation
+
+ return buffer
+
+
+def create_pie_chart(
+ labels: list[str],
+ values: list[float],
+ colors: list[str] | None = None,
+ figsize: tuple[int, int] = (6, 6),
+ dpi: int = DEFAULT_CHART_DPI,
+ autopct: str = "%1.1f%%",
+ startangle: int = 90,
+ title: str | None = None,
+) -> io.BytesIO:
+ """Create a pie chart.
+
+ Args:
+ labels: Slice labels
+ values: Slice values
+ colors: Optional list of colors for slices
+ figsize: Figure size (width, height) in inches
+ dpi: Resolution for output image
+ autopct: Format string for percentage labels
+ startangle: Starting angle for first slice
+ title: Optional chart title
+
+ Returns:
+ BytesIO buffer containing the PNG image
+ """
+ fig, ax = plt.subplots(figsize=figsize)
+
+ _, _, autotexts = ax.pie(
+ values,
+ labels=labels,
+ colors=colors,
+ autopct=autopct,
+ startangle=startangle,
+ )
+
+ # Style the text
+ for autotext in autotexts:
+ autotext.set_fontweight("bold")
+
+ if title:
+ ax.set_title(title, fontsize=14, fontweight="bold")
+
+ plt.tight_layout()
+
+ buffer = io.BytesIO()
+ try:
+ fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
+ buffer.seek(0)
+ finally:
+ plt.close(fig)
+ gc.collect() # Force garbage collection after heavy matplotlib operation
+
+ return buffer
+
+
+def create_stacked_bar_chart(
+ labels: list[str],
+ data_series: dict[str, list[float]],
+ colors: dict[str, str] | None = None,
+ xlabel: str = "",
+ ylabel: str = "Count",
+ title: str | None = None,
+ figsize: tuple[int, int] = (10, 6),
+ dpi: int = DEFAULT_CHART_DPI,
+ rotation: int = 45,
+ show_legend: bool = True,
+) -> io.BytesIO:
+ """Create a stacked bar chart.
+
+ Args:
+ labels: X-axis labels
+ data_series: Dictionary mapping series name to list of values
+ colors: Dictionary mapping series name to color
+ xlabel: X-axis label
+ ylabel: Y-axis label
+ title: Optional chart title
+ figsize: Figure size (width, height) in inches
+ dpi: Resolution for output image
+ rotation: X-axis label rotation angle
+ show_legend: Whether to show the legend
+
+ Returns:
+ BytesIO buffer containing the PNG image
+ """
+ fig, ax = plt.subplots(figsize=figsize)
+
+ # Default colors if not provided
+ default_colors = {
+ "Pass": CHART_COLOR_GREEN_1,
+ "Fail": CHART_COLOR_RED,
+ "Manual": CHART_COLOR_YELLOW,
+ }
+ if colors is None:
+ colors = default_colors
+
+ bottom = [0] * len(labels)
+ for series_name, values in data_series.items():
+ color = colors.get(series_name, CHART_COLOR_BLUE)
+ ax.bar(labels, values, bottom=bottom, label=series_name, color=color)
+ bottom = [b + v for b, v in zip(bottom, values)]
+
+ ax.set_xlabel(xlabel, fontsize=12)
+ ax.set_ylabel(ylabel, fontsize=12)
+
+ if title:
+ ax.set_title(title, fontsize=14, fontweight="bold")
+
+ plt.xticks(rotation=rotation, ha="right")
+
+ if show_legend:
+ ax.legend()
+
+ ax.grid(True, alpha=0.3, axis="y")
+ plt.tight_layout()
+
+ buffer = io.BytesIO()
+ try:
+ fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
+ buffer.seek(0)
+ finally:
+ plt.close(fig)
+ gc.collect() # Force garbage collection after heavy matplotlib operation
+
+ return buffer
diff --git a/api/src/backend/tasks/jobs/reports/components.py b/api/src/backend/tasks/jobs/reports/components.py
new file mode 100644
index 0000000000..323c4547e6
--- /dev/null
+++ b/api/src/backend/tasks/jobs/reports/components.py
@@ -0,0 +1,599 @@
+from dataclasses import dataclass
+from typing import Any, Callable
+
+from reportlab.lib import colors
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import inch
+from reportlab.platypus import LongTable, Paragraph, Spacer, Table, TableStyle
+
+from .config import (
+ ALTERNATE_ROWS_MAX_SIZE,
+ COLOR_BLUE,
+ COLOR_BORDER_GRAY,
+ COLOR_DARK_GRAY,
+ COLOR_GRID_GRAY,
+ COLOR_HIGH_RISK,
+ COLOR_LIGHT_GRAY,
+ COLOR_LOW_RISK,
+ COLOR_MEDIUM_RISK,
+ COLOR_SAFE,
+ COLOR_WHITE,
+ LONG_TABLE_THRESHOLD,
+ PADDING_LARGE,
+ PADDING_MEDIUM,
+ PADDING_SMALL,
+ PADDING_XLARGE,
+)
+
+
+def get_color_for_risk_level(risk_level: int) -> colors.Color:
+ """
+ Get color based on risk level.
+
+ Args:
+ risk_level (int): Numeric risk level (0-5).
+
+ Returns:
+ colors.Color: Appropriate color for the risk level.
+ """
+ if risk_level >= 4:
+ return COLOR_HIGH_RISK
+ if risk_level >= 3:
+ return COLOR_MEDIUM_RISK
+ if risk_level >= 2:
+ return COLOR_LOW_RISK
+ return COLOR_SAFE
+
+
+def get_color_for_weight(weight: int) -> colors.Color:
+ """
+ Get color based on weight value.
+
+ Args:
+ weight (int): Numeric weight value.
+
+ Returns:
+ colors.Color: Appropriate color for the weight.
+ """
+ if weight > 100:
+ return COLOR_HIGH_RISK
+ if weight > 50:
+ return COLOR_LOW_RISK
+ return COLOR_SAFE
+
+
+def get_color_for_compliance(percentage: float) -> colors.Color:
+ """
+ Get color based on compliance percentage.
+
+ Args:
+ percentage (float): Compliance percentage (0-100).
+
+ Returns:
+ colors.Color: Appropriate color for the compliance level.
+ """
+ if percentage >= 80:
+ return COLOR_SAFE
+ if percentage >= 60:
+ return COLOR_LOW_RISK
+ return COLOR_HIGH_RISK
+
+
+def get_status_color(status: str) -> colors.Color:
+ """
+ Get color for a status value.
+
+ Args:
+ status (str): Status string (PASS, FAIL, MANUAL, etc.).
+
+ Returns:
+ colors.Color: Appropriate color for the status.
+ """
+ status_upper = status.upper()
+ if status_upper == "PASS":
+ return COLOR_SAFE
+ if status_upper == "FAIL":
+ return COLOR_HIGH_RISK
+ return COLOR_DARK_GRAY
+
+
+def create_badge(
+ text: str,
+ bg_color: colors.Color,
+ text_color: colors.Color = COLOR_WHITE,
+ width: float = 1.4 * inch,
+ font: str = "FiraCode",
+ font_size: int = 11,
+) -> Table:
+ """
+ Create a generic colored badge component.
+
+ Args:
+ text (str): Text to display in the badge.
+ bg_color (colors.Color): Background color.
+ text_color (colors.Color): Text color (default white).
+ width (float): Badge width in inches.
+ font (str): Font name to use.
+ font_size (int): Font size.
+
+ Returns:
+ Table: A Table object styled as a badge.
+ """
+ data = [[text]]
+ table = Table(data, colWidths=[width])
+
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), bg_color),
+ ("TEXTCOLOR", (0, 0), (0, 0), text_color),
+ ("FONTNAME", (0, 0), (0, 0), font),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 0), (-1, -1), font_size),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
+ ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ]
+ )
+ )
+
+ return table
+
+
+def create_status_badge(status: str) -> Table:
+ """
+ Create a PASS/FAIL/MANUAL status badge.
+
+ Args:
+ status (str): Status value (e.g., "PASS", "FAIL", "MANUAL").
+
+ Returns:
+ Table: A styled Table badge for the status.
+ """
+ status_upper = status.upper()
+ status_color = get_status_color(status_upper)
+
+ data = [["State:", status_upper]]
+ table = Table(data, colWidths=[0.6 * inch, 0.8 * inch])
+
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY),
+ ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"),
+ ("BACKGROUND", (1, 0), (1, 0), status_color),
+ ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
+ ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 0), (-1, -1), 12),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
+ ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ("TOPPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
+ ]
+ )
+ )
+
+ return table
+
+
+def create_multi_badge_row(
+ badges: list[tuple[str, colors.Color]],
+ badge_width: float = 0.4 * inch,
+ font: str = "FiraCode",
+) -> Table:
+ """
+ Create a row of multiple small badges.
+
+ Args:
+ badges (list[tuple[str, colors.Color]]): List of (text, color) tuples for each badge.
+ badge_width (float): Width of each badge.
+ font (str): Font name to use.
+
+ Returns:
+ Table: A Table with multiple colored badges in a row.
+ """
+ if not badges:
+ data = [["N/A"]]
+ table = Table(data, colWidths=[1 * inch])
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
+ ]
+ )
+ )
+ return table
+
+ data = [[text for text, _ in badges]]
+ col_widths = [badge_width] * len(badges)
+ table = Table(data, colWidths=col_widths)
+
+ styles = [
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTNAME", (0, 0), (-1, -1), font),
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
+ ("TEXTCOLOR", (0, 0), (-1, -1), COLOR_WHITE),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
+ ("LEFTPADDING", (0, 0), (-1, -1), PADDING_SMALL),
+ ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_SMALL),
+ ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
+ ]
+
+ for idx, (_, badge_color) in enumerate(badges):
+ styles.append(("BACKGROUND", (idx, 0), (idx, 0), badge_color))
+
+ table.setStyle(TableStyle(styles))
+ return table
+
+
+def create_risk_component(
+ risk_level: int,
+ weight: int,
+ score: int = 0,
+) -> Table:
+ """
+ Create a visual risk component showing risk level, weight, and score.
+
+ Args:
+ risk_level (int): The risk level (0-5).
+ weight (int): The weight value.
+ score (int): The calculated score (default 0).
+
+ Returns:
+ Table: A styled Table showing risk metrics.
+ """
+ risk_color = get_color_for_risk_level(risk_level)
+ weight_color = get_color_for_weight(weight)
+
+ data = [
+ [
+ "Risk Level:",
+ str(risk_level),
+ "Weight:",
+ str(weight),
+ "Score:",
+ str(score),
+ ]
+ ]
+
+ table = Table(
+ data,
+ colWidths=[
+ 0.8 * inch,
+ 0.4 * inch,
+ 0.6 * inch,
+ 0.4 * inch,
+ 0.5 * inch,
+ 0.4 * inch,
+ ],
+ )
+
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY),
+ ("BACKGROUND", (1, 0), (1, 0), risk_color),
+ ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
+ ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
+ ("BACKGROUND", (2, 0), (2, 0), COLOR_LIGHT_GRAY),
+ ("BACKGROUND", (3, 0), (3, 0), weight_color),
+ ("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE),
+ ("FONTNAME", (3, 0), (3, 0), "FiraCode"),
+ ("BACKGROUND", (4, 0), (4, 0), COLOR_LIGHT_GRAY),
+ ("BACKGROUND", (5, 0), (5, 0), COLOR_DARK_GRAY),
+ ("TEXTCOLOR", (5, 0), (5, 0), COLOR_WHITE),
+ ("FONTNAME", (5, 0), (5, 0), "FiraCode"),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
+ ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
+ ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
+ ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ]
+ )
+ )
+
+ return table
+
+
+def create_info_table(
+ rows: list[tuple[str, Any]],
+ label_width: float = 2 * inch,
+ value_width: float = 4 * inch,
+ label_color: colors.Color = COLOR_BLUE,
+ value_bg_color: colors.Color | None = None,
+ normal_style: ParagraphStyle | None = None,
+) -> Table:
+ """
+ Create a key-value information table.
+
+ Args:
+ rows (list[tuple[str, Any]]): List of (label, value) tuples.
+ label_width (float): Width of the label column.
+ value_width (float): Width of the value column.
+ label_color (colors.Color): Background color for labels.
+ value_bg_color (colors.Color | None): Background color for values (optional).
+ normal_style (ParagraphStyle | None): ParagraphStyle for wrapping long values.
+
+ Returns:
+ Table: A styled Table with key-value pairs.
+ """
+ from .config import COLOR_BG_BLUE
+
+ if value_bg_color is None:
+ value_bg_color = COLOR_BG_BLUE
+
+ # Handle empty rows case - Table requires at least one row
+ if not rows:
+ table = Table([["", ""]], colWidths=[label_width, value_width])
+ table.setStyle(TableStyle([("FONTSIZE", (0, 0), (-1, -1), 0)]))
+ return table
+
+ # Process rows - wrap long values in Paragraph if style provided
+ table_data = []
+ for label, value in rows:
+ if normal_style and isinstance(value, str) and len(value) > 50:
+ value = Paragraph(value, normal_style)
+ table_data.append([label, value])
+
+ table = Table(table_data, colWidths=[label_width, value_width])
+
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, -1), label_color),
+ ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (0, -1), "FiraCode"),
+ ("BACKGROUND", (1, 0), (1, -1), value_bg_color),
+ ("TEXTCOLOR", (1, 0), (1, -1), COLOR_DARK_GRAY),
+ ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
+ ("ALIGN", (0, 0), (-1, -1), "LEFT"),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ("FONTSIZE", (0, 0), (-1, -1), 11),
+ ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY),
+ ("LEFTPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
+ ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_XLARGE),
+ ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE),
+ ]
+ )
+ )
+
+ return table
+
+
+@dataclass
+class ColumnConfig:
+ """
+ Configuration for a table column.
+
+ Attributes:
+ header (str): Column header text.
+ width (float): Column width in inches.
+ field (str | Callable[[Any], str]): Field name or callable to extract value from data.
+ align (str): Text alignment (LEFT, CENTER, RIGHT).
+ """
+
+ header: str
+ width: float
+ field: str | Callable[[Any], str]
+ align: str = "CENTER"
+
+
+def create_data_table(
+ data: list[dict[str, Any]],
+ columns: list[ColumnConfig],
+ header_color: colors.Color = COLOR_BLUE,
+ alternate_rows: bool = True,
+ normal_style: ParagraphStyle | None = None,
+) -> Table | LongTable:
+ """
+ Create a data table with configurable columns.
+
+ Uses LongTable for large datasets (>50 rows) for better memory efficiency
+ and page splitting. LongTable repeats headers on each page and has
+ optimized memory handling for large tables.
+
+ Args:
+ data (list[dict[str, Any]]): List of data dictionaries.
+ columns (list[ColumnConfig]): Column configuration list.
+ header_color (colors.Color): Background color for header row.
+ alternate_rows (bool): Whether to alternate row backgrounds.
+ normal_style (ParagraphStyle | None): ParagraphStyle for cell values.
+
+ Returns:
+ Table or LongTable: A styled table with data.
+ """
+ # Build header row
+ header_row = [col.header for col in columns]
+ table_data = [header_row]
+
+ # Build data rows
+ for item in data:
+ row = []
+ for col in columns:
+ if callable(col.field):
+ value = col.field(item)
+ else:
+ value = item.get(col.field, "")
+
+ if normal_style and isinstance(value, str):
+ value = Paragraph(value, normal_style)
+ row.append(value)
+ table_data.append(row)
+
+ col_widths = [col.width for col in columns]
+
+ # Use LongTable for large datasets - it handles page breaks better
+ # and has optimized memory handling for tables with many rows
+ use_long_table = len(data) > LONG_TABLE_THRESHOLD
+ if use_long_table:
+ table = LongTable(table_data, colWidths=col_widths, repeatRows=1)
+ else:
+ table = Table(table_data, colWidths=col_widths)
+
+ styles = [
+ ("BACKGROUND", (0, 0), (-1, 0), header_color),
+ ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
+ ("FONTSIZE", (0, 0), (-1, 0), 10),
+ ("FONTSIZE", (0, 1), (-1, -1), 9),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY),
+ ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
+ ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
+ ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM),
+ ]
+
+ # Apply column alignments
+ for idx, col in enumerate(columns):
+ styles.append(("ALIGN", (idx, 0), (idx, -1), col.align))
+
+ # Alternate row backgrounds - skip for very large tables as it adds memory overhead
+ if (
+ alternate_rows
+ and len(table_data) > 1
+ and len(table_data) <= ALTERNATE_ROWS_MAX_SIZE
+ ):
+ for i in range(1, len(table_data)):
+ if i % 2 == 0:
+ styles.append(
+ ("BACKGROUND", (0, i), (-1, i), colors.Color(0.98, 0.98, 0.98))
+ )
+
+ table.setStyle(TableStyle(styles))
+ return table
+
+
+def create_findings_table(
+ findings: list[Any],
+ columns: list[ColumnConfig] | None = None,
+ header_color: colors.Color = COLOR_BLUE,
+ normal_style: ParagraphStyle | None = None,
+) -> Table:
+ """
+ Create a findings table with default or custom columns.
+
+ Args:
+ findings (list[Any]): List of finding objects.
+ columns (list[ColumnConfig] | None): Optional column configuration (defaults to standard columns).
+ header_color (colors.Color): Background color for header row.
+ normal_style (ParagraphStyle | None): ParagraphStyle for cell values.
+
+ Returns:
+ Table: A styled Table with findings data.
+ """
+ if columns is None:
+ columns = [
+ ColumnConfig("Finding", 2.5 * inch, "title"),
+ ColumnConfig("Resource", 3 * inch, "resource_name"),
+ ColumnConfig("Severity", 0.9 * inch, "severity"),
+ ColumnConfig("Status", 0.9 * inch, "status"),
+ ColumnConfig("Region", 0.9 * inch, "region"),
+ ]
+
+ # Convert findings to dicts
+ data = []
+ for finding in findings:
+ item = {}
+ for col in columns:
+ if callable(col.field):
+ item[col.header.lower()] = col.field(finding)
+ elif hasattr(finding, col.field):
+ item[col.field] = getattr(finding, col.field, "")
+ elif isinstance(finding, dict):
+ item[col.field] = finding.get(col.field, "")
+ data.append(item)
+
+ return create_data_table(
+ data=data,
+ columns=columns,
+ header_color=header_color,
+ alternate_rows=True,
+ normal_style=normal_style,
+ )
+
+
+def create_section_header(
+ text: str,
+ style: ParagraphStyle,
+ add_spacer: bool = True,
+ spacer_height: float = 0.2,
+) -> list:
+ """
+ Create a section header with optional spacer.
+
+ Args:
+ text (str): Header text.
+ style (ParagraphStyle): ParagraphStyle to apply.
+ add_spacer (bool): Whether to add a spacer after the header.
+ spacer_height (float): Height of the spacer in inches.
+
+ Returns:
+ list: List of elements (Paragraph and optional Spacer).
+ """
+ elements = [Paragraph(text, style)]
+ if add_spacer:
+ elements.append(Spacer(1, spacer_height * inch))
+ return elements
+
+
+def create_summary_table(
+ label: str,
+ value: str,
+ value_color: colors.Color,
+ label_width: float = 2.5 * inch,
+ value_width: float = 2 * inch,
+) -> Table:
+ """
+ Create a summary metric table (e.g., for ThreatScore display).
+
+ Args:
+ label (str): Label text (e.g., "ThreatScore:").
+ value (str): Value text (e.g., "85.5%").
+ value_color (colors.Color): Background color for the value cell.
+ label_width (float): Width of the label column.
+ value_width (float): Width of the value column.
+
+ Returns:
+ Table: A styled summary Table.
+ """
+ data = [[label, value]]
+ table = Table(data, colWidths=[label_width, value_width])
+
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)),
+ ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (0, 0), "FiraCode"),
+ ("FONTSIZE", (0, 0), (0, 0), 12),
+ ("BACKGROUND", (1, 0), (1, 0), value_color),
+ ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
+ ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
+ ("FONTSIZE", (1, 0), (1, 0), 16),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)),
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
+ ("TOPPADDING", (0, 0), (-1, -1), 10),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
+ ]
+ )
+ )
+
+ return table
diff --git a/api/src/backend/tasks/jobs/reports/config.py b/api/src/backend/tasks/jobs/reports/config.py
new file mode 100644
index 0000000000..0785505820
--- /dev/null
+++ b/api/src/backend/tasks/jobs/reports/config.py
@@ -0,0 +1,286 @@
+from dataclasses import dataclass, field
+
+from reportlab.lib import colors
+from reportlab.lib.units import inch
+
+# =============================================================================
+# Performance & Memory Optimization Settings
+# =============================================================================
+# These settings control memory usage and performance for large reports.
+# Adjust these values if workers are running out of memory.
+
+# Chart settings - lower DPI = less memory, 150 is good quality for PDF
+CHART_DPI_DEFAULT = 150
+
+# LongTable threshold - use LongTable for tables with more rows than this
+# LongTable handles page breaks better and has optimized memory for large tables
+LONG_TABLE_THRESHOLD = 50
+
+# Skip alternating row colors for tables larger than this (reduces memory)
+ALTERNATE_ROWS_MAX_SIZE = 200
+
+# Database query batch size for findings (matches Django settings)
+# Larger = fewer queries but more memory per batch
+FINDINGS_BATCH_SIZE = 2000
+
+
+# =============================================================================
+# Base colors
+# =============================================================================
+COLOR_PROWLER_DARK_GREEN = colors.Color(0.1, 0.5, 0.2)
+COLOR_BLUE = colors.Color(0.2, 0.4, 0.6)
+COLOR_LIGHT_BLUE = colors.Color(0.3, 0.5, 0.7)
+COLOR_LIGHTER_BLUE = colors.Color(0.4, 0.6, 0.8)
+COLOR_BG_BLUE = colors.Color(0.95, 0.97, 1.0)
+COLOR_BG_LIGHT_BLUE = colors.Color(0.98, 0.99, 1.0)
+COLOR_GRAY = colors.Color(0.2, 0.2, 0.2)
+COLOR_LIGHT_GRAY = colors.Color(0.9, 0.9, 0.9)
+COLOR_BORDER_GRAY = colors.Color(0.7, 0.8, 0.9)
+COLOR_GRID_GRAY = colors.Color(0.7, 0.7, 0.7)
+COLOR_DARK_GRAY = colors.Color(0.4, 0.4, 0.4)
+COLOR_HEADER_DARK = colors.Color(0.1, 0.3, 0.5)
+COLOR_HEADER_MEDIUM = colors.Color(0.15, 0.35, 0.55)
+COLOR_WHITE = colors.white
+
+# Risk and status colors
+COLOR_HIGH_RISK = colors.Color(0.8, 0.2, 0.2)
+COLOR_MEDIUM_RISK = colors.Color(0.9, 0.6, 0.2)
+COLOR_LOW_RISK = colors.Color(0.9, 0.9, 0.2)
+COLOR_SAFE = colors.Color(0.2, 0.8, 0.2)
+
+# ENS specific colors
+COLOR_ENS_ALTO = colors.Color(0.8, 0.2, 0.2)
+COLOR_ENS_MEDIO = colors.Color(0.98, 0.75, 0.13)
+COLOR_ENS_BAJO = colors.Color(0.06, 0.72, 0.51)
+COLOR_ENS_OPCIONAL = colors.Color(0.42, 0.45, 0.50)
+COLOR_ENS_TIPO = colors.Color(0.2, 0.4, 0.6)
+COLOR_ENS_AUTO = colors.Color(0.30, 0.69, 0.31)
+COLOR_ENS_MANUAL = colors.Color(0.96, 0.60, 0.0)
+
+# NIS2 specific colors
+COLOR_NIS2_PRIMARY = colors.Color(0.12, 0.23, 0.54)
+COLOR_NIS2_SECONDARY = colors.Color(0.23, 0.51, 0.96)
+COLOR_NIS2_BG_BLUE = colors.Color(0.96, 0.97, 0.99)
+
+# Chart colors (hex strings for matplotlib)
+CHART_COLOR_GREEN_1 = "#4CAF50"
+CHART_COLOR_GREEN_2 = "#8BC34A"
+CHART_COLOR_YELLOW = "#FFEB3B"
+CHART_COLOR_ORANGE = "#FF9800"
+CHART_COLOR_RED = "#F44336"
+CHART_COLOR_BLUE = "#2196F3"
+
+# ENS dimension mappings: dimension name -> (abbreviation, color)
+DIMENSION_MAPPING = {
+ "trazabilidad": ("T", colors.Color(0.26, 0.52, 0.96)),
+ "autenticidad": ("A", colors.Color(0.30, 0.69, 0.31)),
+ "integridad": ("I", colors.Color(0.61, 0.15, 0.69)),
+ "confidencialidad": ("C", colors.Color(0.96, 0.26, 0.21)),
+ "disponibilidad": ("D", colors.Color(1.0, 0.60, 0.0)),
+}
+
+# ENS tipo icons
+TIPO_ICONS = {
+ "requisito": "\u26a0\ufe0f",
+ "refuerzo": "\U0001f6e1\ufe0f",
+ "recomendacion": "\U0001f4a1",
+ "medida": "\U0001f4cb",
+}
+
+# Dimension names for charts (Spanish)
+DIMENSION_NAMES = [
+ "Trazabilidad",
+ "Autenticidad",
+ "Integridad",
+ "Confidencialidad",
+ "Disponibilidad",
+]
+
+DIMENSION_KEYS = [
+ "trazabilidad",
+ "autenticidad",
+ "integridad",
+ "confidencialidad",
+ "disponibilidad",
+]
+
+# ENS nivel and tipo order
+ENS_NIVEL_ORDER = ["alto", "medio", "bajo", "opcional"]
+ENS_TIPO_ORDER = ["requisito", "refuerzo", "recomendacion", "medida"]
+
+# ThreatScore sections
+THREATSCORE_SECTIONS = [
+ "1. IAM",
+ "2. Attack Surface",
+ "3. Logging and Monitoring",
+ "4. Encryption",
+]
+
+# NIS2 sections
+NIS2_SECTIONS = [
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "9",
+ "11",
+ "12",
+]
+
+NIS2_SECTION_TITLES = {
+ "1": "1. Policy on Security",
+ "2": "2. Risk Management",
+ "3": "3. Incident Handling",
+ "4": "4. Business Continuity",
+ "5": "5. Supply Chain",
+ "6": "6. Acquisition & Dev",
+ "7": "7. Effectiveness",
+ "9": "9. Cryptography",
+ "11": "11. Access Control",
+ "12": "12. Asset Management",
+}
+
+# Table column widths
+COL_WIDTH_SMALL = 0.4 * inch
+COL_WIDTH_MEDIUM = 0.9 * inch
+COL_WIDTH_LARGE = 1.5 * inch
+COL_WIDTH_XLARGE = 2 * inch
+COL_WIDTH_XXLARGE = 3 * inch
+
+# Common padding values
+PADDING_SMALL = 4
+PADDING_MEDIUM = 6
+PADDING_LARGE = 8
+PADDING_XLARGE = 10
+
+
+@dataclass
+class FrameworkConfig:
+ """
+ Configuration for a compliance framework PDF report.
+
+ This dataclass defines all the configurable aspects of a compliance framework
+ report, including visual styling, metadata fields, and feature flags.
+
+ Attributes:
+ name (str): Internal framework identifier (e.g., "prowler_threatscore").
+ display_name (str): Human-readable framework name for the report title.
+ logo_filename (str | None): Optional filename of the framework logo in assets/img/.
+ primary_color (colors.Color): Main color used for headers and important elements.
+ secondary_color (colors.Color): Secondary color for sub-headers and accents.
+ bg_color (colors.Color): Background color for highlighted sections.
+ attribute_fields (list[str]): List of metadata field names to extract from requirements.
+ sections (list[str] | None): Optional ordered list of section names for grouping.
+ language (str): Report language ("en" for English, "es" for Spanish).
+ has_risk_levels (bool): Whether the framework uses numeric risk levels.
+ has_dimensions (bool): Whether the framework uses security dimensions (ENS).
+ has_niveles (bool): Whether the framework uses nivel classification (ENS).
+ has_weight (bool): Whether requirements have weight values.
+ """
+
+ name: str
+ display_name: str
+ logo_filename: str | None = None
+ primary_color: colors.Color = field(default_factory=lambda: COLOR_BLUE)
+ secondary_color: colors.Color = field(default_factory=lambda: COLOR_LIGHT_BLUE)
+ bg_color: colors.Color = field(default_factory=lambda: COLOR_BG_BLUE)
+ attribute_fields: list[str] = field(default_factory=list)
+ sections: list[str] | None = None
+ language: str = "en"
+ has_risk_levels: bool = False
+ has_dimensions: bool = False
+ has_niveles: bool = False
+ has_weight: bool = False
+
+
+FRAMEWORK_REGISTRY: dict[str, FrameworkConfig] = {
+ "prowler_threatscore": FrameworkConfig(
+ name="prowler_threatscore",
+ display_name="Prowler ThreatScore",
+ logo_filename=None,
+ primary_color=COLOR_BLUE,
+ secondary_color=COLOR_LIGHT_BLUE,
+ bg_color=COLOR_BG_BLUE,
+ attribute_fields=[
+ "Title",
+ "Section",
+ "SubSection",
+ "LevelOfRisk",
+ "Weight",
+ "AttributeDescription",
+ "AdditionalInformation",
+ ],
+ sections=THREATSCORE_SECTIONS,
+ language="en",
+ has_risk_levels=True,
+ has_weight=True,
+ ),
+ "ens": FrameworkConfig(
+ name="ens",
+ display_name="ENS RD2022",
+ logo_filename="ens_logo.png",
+ primary_color=COLOR_ENS_ALTO,
+ secondary_color=COLOR_ENS_MEDIO,
+ bg_color=COLOR_BG_BLUE,
+ attribute_fields=[
+ "IdGrupoControl",
+ "Marco",
+ "Categoria",
+ "DescripcionControl",
+ "Tipo",
+ "Nivel",
+ "Dimensiones",
+ "ModoEjecucion",
+ ],
+ sections=None,
+ language="es",
+ has_risk_levels=False,
+ has_dimensions=True,
+ has_niveles=True,
+ has_weight=False,
+ ),
+ "nis2": FrameworkConfig(
+ name="nis2",
+ display_name="NIS2 Directive",
+ logo_filename="nis2_logo.png",
+ primary_color=COLOR_NIS2_PRIMARY,
+ secondary_color=COLOR_NIS2_SECONDARY,
+ bg_color=COLOR_NIS2_BG_BLUE,
+ attribute_fields=[
+ "Section",
+ "SubSection",
+ "Description",
+ ],
+ sections=NIS2_SECTIONS,
+ language="en",
+ has_risk_levels=False,
+ has_dimensions=False,
+ has_niveles=False,
+ has_weight=False,
+ ),
+}
+
+
+def get_framework_config(compliance_id: str) -> FrameworkConfig | None:
+ """
+ Get framework configuration based on compliance ID.
+
+ Args:
+ compliance_id (str): The compliance framework identifier (e.g., "prowler_threatscore_aws").
+
+ Returns:
+ FrameworkConfig | None: The framework configuration if found, None otherwise.
+ """
+ compliance_lower = compliance_id.lower()
+
+ if "threatscore" in compliance_lower:
+ return FRAMEWORK_REGISTRY["prowler_threatscore"]
+ if "ens" in compliance_lower:
+ return FRAMEWORK_REGISTRY["ens"]
+ if "nis2" in compliance_lower:
+ return FRAMEWORK_REGISTRY["nis2"]
+
+ return None
diff --git a/api/src/backend/tasks/jobs/reports/ens.py b/api/src/backend/tasks/jobs/reports/ens.py
new file mode 100644
index 0000000000..56ee4bc40f
--- /dev/null
+++ b/api/src/backend/tasks/jobs/reports/ens.py
@@ -0,0 +1,1004 @@
+import os
+from collections import defaultdict
+
+from reportlab.lib import colors
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import inch
+from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle
+
+from api.models import StatusChoices
+
+from .base import (
+ BaseComplianceReportGenerator,
+ ComplianceData,
+ get_requirement_metadata,
+)
+from .charts import create_horizontal_bar_chart, create_radar_chart
+from .components import get_color_for_compliance
+from .config import (
+ COLOR_BG_BLUE,
+ COLOR_BLUE,
+ COLOR_BORDER_GRAY,
+ COLOR_ENS_ALTO,
+ COLOR_ENS_AUTO,
+ COLOR_ENS_BAJO,
+ COLOR_ENS_MANUAL,
+ COLOR_ENS_MEDIO,
+ COLOR_ENS_OPCIONAL,
+ COLOR_ENS_TIPO,
+ COLOR_GRAY,
+ COLOR_GRID_GRAY,
+ COLOR_HIGH_RISK,
+ COLOR_SAFE,
+ COLOR_WHITE,
+ DIMENSION_KEYS,
+ DIMENSION_MAPPING,
+ DIMENSION_NAMES,
+ ENS_NIVEL_ORDER,
+ ENS_TIPO_ORDER,
+)
+
+
+class ENSReportGenerator(BaseComplianceReportGenerator):
+ """
+ PDF report generator for ENS RD2022 framework.
+
+ This generator creates comprehensive PDF reports containing:
+ - Cover page with both Prowler and ENS logos
+ - Executive summary with overall compliance score
+ - Marco/CategorΓa analysis with charts
+ - Security dimensions radar chart
+ - Requirement type distribution
+ - Execution mode distribution
+ - Critical failed requirements (nivel alto)
+ - Requirements index
+ - Detailed findings for failed and manual requirements
+ """
+
+ def create_cover_page(self, data: ComplianceData) -> list:
+ """
+ Create the ENS report cover page with both logos and legend.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ # Create logos side by side
+ prowler_logo_path = os.path.join(
+ os.path.dirname(__file__), "../../assets/img/prowler_logo.png"
+ )
+ ens_logo_path = os.path.join(
+ os.path.dirname(__file__), "../../assets/img/ens_logo.png"
+ )
+
+ prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch)
+ ens_logo = Image(ens_logo_path, width=1.5 * inch, height=2 * inch)
+
+ logos_table = Table(
+ [[prowler_logo, ens_logo]], colWidths=[4 * inch, 2.5 * inch]
+ )
+ logos_table.setStyle(
+ TableStyle(
+ [
+ ("ALIGN", (0, 0), (0, 0), "LEFT"),
+ ("ALIGN", (1, 0), (1, 0), "RIGHT"),
+ ("VALIGN", (0, 0), (0, 0), "MIDDLE"),
+ ("VALIGN", (1, 0), (1, 0), "TOP"),
+ ]
+ )
+ )
+ elements.append(logos_table)
+ elements.append(Spacer(1, 0.3 * inch))
+ elements.append(
+ Paragraph("Informe de Cumplimiento ENS RD 311/2022", self.styles["title"])
+ )
+ elements.append(Spacer(1, 0.5 * inch))
+
+ # Compliance info table - use base class helper for consistency
+ info_rows = self._build_info_rows(data, language="es")
+ # Convert tuples to lists and wrap long text in Paragraphs
+ info_data = []
+ for label, value in info_rows:
+ if label in ("Nombre:", "DescripciΓ³n:") and value:
+ info_data.append(
+ [label, Paragraph(value, self.styles["normal_center"])]
+ )
+ else:
+ info_data.append([label, value])
+
+ info_table = Table(info_data, colWidths=[2 * inch, 4 * inch])
+ info_table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE),
+ ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (0, -1), "FiraCode"),
+ ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE),
+ ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY),
+ ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
+ ("ALIGN", (0, 0), (-1, -1), "LEFT"),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ("FONTSIZE", (0, 0), (-1, -1), 11),
+ ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)),
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
+ ]
+ )
+ )
+ elements.append(info_table)
+ elements.append(Spacer(1, 0.5 * inch))
+
+ # Warning about excluded manual requirements
+ manual_count = self._count_manual_requirements(data)
+ auto_count = len(
+ [r for r in data.requirements if r.status != StatusChoices.MANUAL]
+ )
+
+ warning_text = (
+ f"AVISO: Este informe no incluye los requisitos de ejecuciΓ³n manual. "
+ f"El compliance {data.compliance_id} contiene un total de "
+ f"{manual_count} requisitos manuales que no han sido evaluados "
+ f"automΓ‘ticamente y por tanto no estΓ‘n reflejados en las estadΓsticas de este reporte. "
+ f"El anΓ‘lisis se basa ΓΊnicamente en los {auto_count} requisitos automatizados."
+ )
+ warning_paragraph = Paragraph(warning_text, self.styles["normal"])
+ warning_table = Table([[warning_paragraph]], colWidths=[6 * inch])
+ warning_table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), colors.Color(1.0, 0.95, 0.7)),
+ ("TEXTCOLOR", (0, 0), (0, 0), colors.Color(0.4, 0.3, 0.0)),
+ ("ALIGN", (0, 0), (0, 0), "LEFT"),
+ ("VALIGN", (0, 0), (0, 0), "MIDDLE"),
+ ("BOX", (0, 0), (-1, -1), 2, colors.Color(0.9, 0.7, 0.0)),
+ ("LEFTPADDING", (0, 0), (-1, -1), 15),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 15),
+ ("TOPPADDING", (0, 0), (-1, -1), 12),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 12),
+ ]
+ )
+ )
+ elements.append(warning_table)
+ elements.append(Spacer(1, 0.5 * inch))
+
+ # Legend
+ elements.append(self._create_legend())
+
+ return elements
+
+ def create_executive_summary(self, data: ComplianceData) -> list:
+ """
+ Create the executive summary with compliance metrics.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ elements.append(Paragraph("Resumen Ejecutivo", self.styles["h1"]))
+ elements.append(Spacer(1, 0.2 * inch))
+
+ # Filter out manual requirements
+ auto_requirements = [
+ r for r in data.requirements if r.status != StatusChoices.MANUAL
+ ]
+ total = len(auto_requirements)
+ passed = sum(1 for r in auto_requirements if r.status == StatusChoices.PASS)
+ failed = sum(1 for r in auto_requirements if r.status == StatusChoices.FAIL)
+
+ overall_compliance = (passed / total * 100) if total > 0 else 0
+ compliance_color = get_color_for_compliance(overall_compliance)
+
+ # Summary table
+ summary_data = [["Nivel de Cumplimiento Global:", f"{overall_compliance:.2f}%"]]
+ summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch])
+ summary_table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)),
+ ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (0, 0), "FiraCode"),
+ ("FONTSIZE", (0, 0), (0, 0), 12),
+ ("BACKGROUND", (1, 0), (1, 0), compliance_color),
+ ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
+ ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
+ ("FONTSIZE", (1, 0), (1, 0), 16),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)),
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
+ ("TOPPADDING", (0, 0), (-1, -1), 10),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
+ ]
+ )
+ )
+ elements.append(summary_table)
+ elements.append(Spacer(1, 0.3 * inch))
+
+ # Counts table
+ counts_data = [
+ ["Estado", "Cantidad", "Porcentaje"],
+ [
+ "CUMPLE",
+ str(passed),
+ f"{(passed / total * 100):.1f}%" if total > 0 else "0%",
+ ],
+ [
+ "NO CUMPLE",
+ str(failed),
+ f"{(failed / total * 100):.1f}%" if total > 0 else "0%",
+ ],
+ ["TOTAL", str(total), "100%"],
+ ]
+ counts_table = Table(counts_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch])
+ counts_table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
+ ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
+ ("BACKGROUND", (0, 1), (0, 1), COLOR_SAFE),
+ ("TEXTCOLOR", (0, 1), (0, 1), COLOR_WHITE),
+ ("BACKGROUND", (0, 2), (0, 2), COLOR_HIGH_RISK),
+ ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE),
+ ("BACKGROUND", (0, 3), (0, 3), colors.Color(0.4, 0.4, 0.4)),
+ ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
+ ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY),
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+ ]
+ )
+ )
+ elements.append(counts_table)
+ elements.append(Spacer(1, 0.3 * inch))
+
+ # Compliance by Nivel
+ elements.extend(self._create_nivel_table(data))
+
+ return elements
+
+ def create_charts_section(self, data: ComplianceData) -> list:
+ """
+ Create the charts section with Marco analysis and radar chart.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ # Critical failed requirements section (nivel alto) - new page
+ elements.append(PageBreak())
+ elements.extend(self._create_critical_failed_section(data))
+
+ # Marco y CategorΓas chart - new page
+ elements.append(PageBreak())
+ elements.append(
+ Paragraph("AnΓ‘lisis por Marcos y CategorΓas", self.styles["h1"])
+ )
+ elements.append(Spacer(1, 0.2 * inch))
+
+ marco_cat_chart = self._create_marco_category_chart(data)
+ marco_cat_image = Image(marco_cat_chart, width=7 * inch, height=5.5 * inch)
+ elements.append(marco_cat_image)
+
+ # Security dimensions radar chart - new page
+ elements.append(PageBreak())
+ elements.append(
+ Paragraph("AnΓ‘lisis por Dimensiones de Seguridad", self.styles["h1"])
+ )
+ elements.append(Spacer(1, 0.2 * inch))
+
+ radar_buffer = self._create_dimensions_radar_chart(data)
+ radar_image = Image(radar_buffer, width=6 * inch, height=6 * inch)
+ elements.append(radar_image)
+ elements.append(PageBreak())
+
+ # Type distribution
+ elements.extend(self._create_tipo_section(data))
+
+ return elements
+
+ def create_requirements_index(self, data: ComplianceData) -> list:
+ """
+ Create the requirements index organized by Marco and Categoria.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ elements.append(Paragraph("Γndice de Requisitos", self.styles["h1"]))
+ elements.append(Spacer(1, 0.2 * inch))
+
+ # Organize by Marco and Categoria
+ marcos = {}
+ for req in data.requirements:
+ if req.status == StatusChoices.MANUAL:
+ continue
+
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ marco = getattr(m, "Marco", "Otros")
+ categoria = getattr(m, "Categoria", "Sin categorΓa")
+ descripcion = getattr(m, "DescripcionControl", req.description)
+ nivel = getattr(m, "Nivel", "")
+
+ if marco not in marcos:
+ marcos[marco] = {}
+ if categoria not in marcos[marco]:
+ marcos[marco][categoria] = []
+
+ marcos[marco][categoria].append(
+ {
+ "id": req.id,
+ "descripcion": descripcion,
+ "nivel": nivel,
+ "status": req.status,
+ }
+ )
+
+ for marco_name, categorias in marcos.items():
+ elements.append(Paragraph(f"Marco: {marco_name}", self.styles["h2"]))
+
+ for categoria_name, reqs in categorias.items():
+ elements.append(Paragraph(f"{categoria_name}", self.styles["h3"]))
+
+ for req in reqs:
+ status_indicator = (
+ "β" if req["status"] == StatusChoices.PASS else "β"
+ )
+ nivel_badge = f"[{req['nivel'].upper()}]" if req["nivel"] else ""
+ elements.append(
+ Paragraph(
+ f"{status_indicator} {req['id']} {nivel_badge}",
+ self.styles["normal"],
+ )
+ )
+
+ elements.append(Spacer(1, 0.1 * inch))
+
+ return elements
+
+ def get_footer_text(self, page_num: int) -> tuple[str, str]:
+ """
+ Get Spanish footer text for ENS report.
+
+ Args:
+ page_num: Current page number.
+
+ Returns:
+ Tuple of (left_text, right_text) for the footer.
+ """
+ return f"PΓ‘gina {page_num}", "Powered by Prowler"
+
+ def _count_manual_requirements(self, data: ComplianceData) -> int:
+ """Count requirements with manual execution mode."""
+ return sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL)
+
+ def _create_legend(self) -> Table:
+ """Create the ENS values legend table."""
+ legend_text = """
+ Nivel (Criticidad del requisito):
+ β’ Alto: Requisitos crΓticos que deben cumplirse prioritariamente
+ β’ Medio: Requisitos importantes con impacto moderado
+ β’ Bajo: Requisitos complementarios de menor criticidad
+ β’ Opcional: Recomendaciones adicionales no obligatorias
+
+ Tipo (ClasificaciΓ³n del requisito):
+ β’ Requisito: ObligaciΓ³n establecida por el ENS
+ β’ Refuerzo: Medida adicional que refuerza un requisito
+ β’ RecomendaciΓ³n: Buena prΓ‘ctica sugerida
+ β’ Medida: AcciΓ³n concreta de implementaciΓ³n
+
+ Dimensiones de Seguridad:
+ β’ C (Confidencialidad): ProtecciΓ³n contra accesos no autorizados
+ β’ I (Integridad): GarantΓa de exactitud y completitud
+ β’ T (Trazabilidad): Capacidad de rastrear acciones
+ β’ A (Autenticidad): VerificaciΓ³n de identidad
+ β’ D (Disponibilidad): Acceso cuando se necesita
+ """
+ legend_paragraph = Paragraph(legend_text, self.styles["normal"])
+ legend_table = Table([[legend_paragraph]], colWidths=[6.5 * inch])
+ legend_table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), COLOR_BG_BLUE),
+ ("TEXTCOLOR", (0, 0), (0, 0), COLOR_GRAY),
+ ("ALIGN", (0, 0), (0, 0), "LEFT"),
+ ("VALIGN", (0, 0), (0, 0), "TOP"),
+ ("BOX", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.8)),
+ ("LEFTPADDING", (0, 0), (-1, -1), 15),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 15),
+ ("TOPPADDING", (0, 0), (-1, -1), 12),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 12),
+ ]
+ )
+ )
+ return legend_table
+
+ def _create_nivel_table(self, data: ComplianceData) -> list:
+ """Create compliance by nivel table."""
+ elements = []
+ elements.append(Paragraph("Cumplimiento por Nivel", self.styles["h2"]))
+
+ nivel_data = defaultdict(lambda: {"passed": 0, "total": 0})
+ for req in data.requirements:
+ if req.status == StatusChoices.MANUAL:
+ continue
+
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ nivel = getattr(m, "Nivel", "").lower()
+ nivel_data[nivel]["total"] += 1
+ if req.status == StatusChoices.PASS:
+ nivel_data[nivel]["passed"] += 1
+
+ table_data = [["Nivel", "Cumplidos", "Total", "Porcentaje"]]
+ nivel_colors = {
+ "alto": COLOR_ENS_ALTO,
+ "medio": COLOR_ENS_MEDIO,
+ "bajo": COLOR_ENS_BAJO,
+ "opcional": COLOR_ENS_OPCIONAL,
+ }
+
+ for nivel in ENS_NIVEL_ORDER:
+ if nivel in nivel_data:
+ d = nivel_data[nivel]
+ pct = (d["passed"] / d["total"] * 100) if d["total"] > 0 else 0
+ table_data.append(
+ [
+ nivel.capitalize(),
+ str(d["passed"]),
+ str(d["total"]),
+ f"{pct:.1f}%",
+ ]
+ )
+
+ table = Table(
+ table_data, colWidths=[1.5 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch]
+ )
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
+ ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
+ ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY),
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+ ]
+ )
+ )
+
+ # Color nivel column
+ for idx, nivel in enumerate(ENS_NIVEL_ORDER):
+ if nivel in nivel_data:
+ row_idx = idx + 1
+ if row_idx < len(table_data):
+ color = nivel_colors.get(nivel, COLOR_GRAY)
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, row_idx), (0, row_idx), color),
+ ("TEXTCOLOR", (0, row_idx), (0, row_idx), COLOR_WHITE),
+ ]
+ )
+ )
+
+ elements.append(table)
+ return elements
+
+ def _create_marco_category_chart(self, data: ComplianceData):
+ """Create Marco - CategorΓa combined compliance chart."""
+ # Group by marco + categoria combination
+ marco_cat_scores = defaultdict(lambda: {"passed": 0, "total": 0})
+
+ for req in data.requirements:
+ if req.status == StatusChoices.MANUAL:
+ continue
+
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ marco = getattr(m, "Marco", "otros")
+ categoria = getattr(m, "Categoria", "sin categorΓa")
+ # Combined key: "marco - categorΓa"
+ key = f"{marco} - {categoria}"
+ marco_cat_scores[key]["total"] += 1
+ if req.status == StatusChoices.PASS:
+ marco_cat_scores[key]["passed"] += 1
+
+ labels = []
+ values = []
+ for key, scores in sorted(marco_cat_scores.items()):
+ if scores["total"] > 0:
+ pct = (scores["passed"] / scores["total"]) * 100
+ labels.append(key)
+ values.append(pct)
+
+ return create_horizontal_bar_chart(
+ labels=labels,
+ values=values,
+ xlabel="Porcentaje de Cumplimiento (%)",
+ )
+
+ def _create_dimensions_radar_chart(self, data: ComplianceData):
+ """Create security dimensions radar chart."""
+ dimension_scores = {dim: {"passed": 0, "total": 0} for dim in DIMENSION_KEYS}
+
+ for req in data.requirements:
+ if req.status == StatusChoices.MANUAL:
+ continue
+
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ dimensiones = getattr(m, "Dimensiones", [])
+ if isinstance(dimensiones, str):
+ dimensiones = [d.strip().lower() for d in dimensiones.split(",")]
+ elif isinstance(dimensiones, list):
+ dimensiones = [
+ d.lower() if isinstance(d, str) else d for d in dimensiones
+ ]
+
+ for dim in dimensiones:
+ if dim in dimension_scores:
+ dimension_scores[dim]["total"] += 1
+ if req.status == StatusChoices.PASS:
+ dimension_scores[dim]["passed"] += 1
+
+ values = []
+ for dim in DIMENSION_KEYS:
+ scores = dimension_scores[dim]
+ if scores["total"] > 0:
+ pct = (scores["passed"] / scores["total"]) * 100
+ else:
+ pct = 100
+ values.append(pct)
+
+ return create_radar_chart(
+ labels=DIMENSION_NAMES,
+ values=values,
+ color="#2196F3",
+ )
+
+ def _create_tipo_section(self, data: ComplianceData) -> list:
+ """Create type distribution section."""
+ elements = []
+ elements.append(
+ Paragraph("DistribuciΓ³n por Tipo de Requisito", self.styles["h1"])
+ )
+ elements.append(Spacer(1, 0.2 * inch))
+
+ tipo_data = defaultdict(lambda: {"passed": 0, "total": 0})
+ for req in data.requirements:
+ if req.status == StatusChoices.MANUAL:
+ continue
+
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ tipo = getattr(m, "Tipo", "").lower()
+ tipo_data[tipo]["total"] += 1
+ if req.status == StatusChoices.PASS:
+ tipo_data[tipo]["passed"] += 1
+
+ table_data = [["Tipo", "Cumplidos", "Total", "Porcentaje"]]
+ for tipo in ENS_TIPO_ORDER:
+ if tipo in tipo_data:
+ d = tipo_data[tipo]
+ pct = (d["passed"] / d["total"] * 100) if d["total"] > 0 else 0
+ table_data.append(
+ [
+ tipo.capitalize(),
+ str(d["passed"]),
+ str(d["total"]),
+ f"{pct:.1f}%",
+ ]
+ )
+
+ table = Table(
+ table_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch]
+ )
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
+ ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
+ ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY),
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+ ]
+ )
+ )
+ elements.append(table)
+ return elements
+
+ def _create_critical_failed_section(self, data: ComplianceData) -> list:
+ """Create section for critical failed requirements (nivel alto)."""
+ elements = []
+
+ elements.append(
+ Paragraph("Requisitos CrΓticos No Cumplidos", self.styles["h1"])
+ )
+ elements.append(Spacer(1, 0.2 * inch))
+
+ # Get failed requirements with nivel alto
+ critical_failed = []
+ for req in data.requirements:
+ if req.status != StatusChoices.FAIL:
+ continue
+
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ nivel = getattr(m, "Nivel", "").lower()
+ if nivel == "alto":
+ critical_failed.append(
+ {
+ "id": req.id,
+ "descripcion": getattr(
+ m, "DescripcionControl", req.description
+ ),
+ "marco": getattr(m, "Marco", ""),
+ "categoria": getattr(m, "Categoria", ""),
+ "tipo": getattr(m, "Tipo", ""),
+ }
+ )
+
+ if not critical_failed:
+ elements.append(
+ Paragraph(
+ "β
No hay requisitos crΓticos (nivel ALTO) que hayan fallado.",
+ self.styles["normal"],
+ )
+ )
+ return elements
+
+ elements.append(
+ Paragraph(
+ f"Se encontraron {len(critical_failed)} requisitos de nivel ALTO "
+ "que no cumplen y requieren atenciΓ³n inmediata:",
+ self.styles["normal"],
+ )
+ )
+ elements.append(Spacer(1, 0.2 * inch))
+
+ # Create table - use a cell style without leftIndent for proper alignment
+ cell_style = ParagraphStyle(
+ "CellStyle",
+ parent=self.styles["normal"],
+ leftIndent=0,
+ spaceBefore=0,
+ spaceAfter=0,
+ )
+ table_data: list = [["ID Requisito", "Marco", "CategorΓa", "Tipo"]]
+ for req in critical_failed:
+ table_data.append(
+ [
+ req["id"],
+ req["marco"],
+ Paragraph(req["categoria"], cell_style),
+ req["tipo"].capitalize() if req["tipo"] else "",
+ ]
+ )
+
+ table = Table(
+ table_data,
+ colWidths=[2 * inch, 1.5 * inch, 1.8 * inch, 1.2 * inch],
+ )
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (-1, 0), COLOR_ENS_ALTO),
+ ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
+ ("FONTSIZE", (0, 0), (-1, 0), 10),
+ ("ALIGN", (0, 0), (-1, -1), "LEFT"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 1), (-1, -1), 9),
+ ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
+ (
+ "ROWBACKGROUNDS",
+ (0, 1),
+ (-1, -1),
+ [COLOR_WHITE, colors.Color(1.0, 0.95, 0.95)],
+ ),
+ ]
+ )
+ )
+ elements.append(table)
+
+ return elements
+
+ def create_detailed_findings(self, data: ComplianceData, **kwargs) -> list:
+ """
+ Create detailed findings section with ENS-specific format.
+
+ Shows each failed requirement with:
+ - Requirement ID as title
+ - Status, Nivel, Tipo, ModoEjecucion badges
+ - Dimensiones badges
+ - Info table with DescripciΓ³n, Marco, CategorΓa, etc.
+
+ Args:
+ data: Aggregated compliance data.
+ **kwargs: Additional options.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+ include_manual = kwargs.get("include_manual", True)
+
+ elements.append(Paragraph("Detalle de Requisitos", self.styles["h1"]))
+ elements.append(Spacer(1, 0.2 * inch))
+
+ # Get failed requirements, and optionally manual requirements
+ if include_manual:
+ failed_requirements = [
+ r
+ for r in data.requirements
+ if r.status in (StatusChoices.FAIL, StatusChoices.MANUAL)
+ ]
+ else:
+ failed_requirements = [
+ r for r in data.requirements if r.status == StatusChoices.FAIL
+ ]
+
+ if not failed_requirements:
+ elements.append(
+ Paragraph(
+ "No hay requisitos fallidos para mostrar.",
+ self.styles["normal"],
+ )
+ )
+ return elements
+
+ elements.append(
+ Paragraph(
+ f"Se muestran {len(failed_requirements)} requisitos que requieren "
+ "atenciΓ³n:",
+ self.styles["normal"],
+ )
+ )
+ elements.append(Spacer(1, 0.3 * inch))
+
+ # Nivel colors mapping
+ nivel_colors = {
+ "alto": COLOR_ENS_ALTO,
+ "medio": COLOR_ENS_MEDIO,
+ "bajo": COLOR_ENS_BAJO,
+ "opcional": COLOR_ENS_OPCIONAL,
+ }
+
+ for req in failed_requirements:
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+
+ if not m:
+ continue
+
+ nivel = getattr(m, "Nivel", "").lower()
+ tipo = getattr(m, "Tipo", "")
+ modo = getattr(m, "ModoEjecucion", "")
+ dimensiones = getattr(m, "Dimensiones", [])
+ descripcion = getattr(m, "DescripcionControl", req.description)
+ marco = getattr(m, "Marco", "")
+ categoria = getattr(m, "Categoria", "")
+ id_grupo = getattr(m, "IdGrupoControl", "")
+
+ # Requirement ID title
+ req_title = Table([[req.id]], colWidths=[6.5 * inch])
+ req_title.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), COLOR_BG_BLUE),
+ ("TEXTCOLOR", (0, 0), (0, 0), COLOR_BLUE),
+ ("FONTNAME", (0, 0), (0, 0), "FiraCode"),
+ ("FONTSIZE", (0, 0), (0, 0), 14),
+ ("ALIGN", (0, 0), (0, 0), "LEFT"),
+ ("BOX", (0, 0), (-1, -1), 2, COLOR_BLUE),
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
+ ("TOPPADDING", (0, 0), (-1, -1), 10),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
+ ]
+ )
+ )
+ elements.append(req_title)
+ elements.append(Spacer(1, 0.15 * inch))
+
+ # Status and Nivel badges row
+ status_color = COLOR_HIGH_RISK # FAIL
+ nivel_color = nivel_colors.get(nivel, COLOR_GRAY)
+
+ badges_row1 = [
+ ["State:", "FAIL", "", f"Nivel: {nivel.upper()}"],
+ ]
+ badges_table1 = Table(
+ badges_row1,
+ colWidths=[0.7 * inch, 0.8 * inch, 1.5 * inch, 1.5 * inch],
+ )
+ badges_table1.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)),
+ ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"),
+ ("BACKGROUND", (1, 0), (1, 0), status_color),
+ ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE),
+ ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
+ ("BACKGROUND", (3, 0), (3, 0), nivel_color),
+ ("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE),
+ ("FONTNAME", (3, 0), (3, 0), "FiraCode"),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 0), (-1, -1), 11),
+ ("GRID", (0, 0), (1, 0), 0.5, colors.black),
+ ("GRID", (3, 0), (3, 0), 0.5, colors.black),
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
+ ]
+ )
+ )
+ elements.append(badges_table1)
+ elements.append(Spacer(1, 0.1 * inch))
+
+ # Tipo and Modo badges row
+ tipo_display = f"β° {tipo.capitalize()}" if tipo else "N/A"
+ modo_display = f"β° {modo.capitalize()}" if modo else "N/A"
+ modo_color = (
+ COLOR_ENS_AUTO if modo.lower() == "automatico" else COLOR_ENS_MANUAL
+ )
+
+ badges_row2 = [[tipo_display, "", modo_display]]
+ badges_table2 = Table(
+ badges_row2, colWidths=[2.2 * inch, 0.5 * inch, 2.2 * inch]
+ )
+ badges_table2.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), COLOR_ENS_TIPO),
+ ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"),
+ ("BACKGROUND", (2, 0), (2, 0), modo_color),
+ ("TEXTCOLOR", (2, 0), (2, 0), COLOR_WHITE),
+ ("FONTNAME", (2, 0), (2, 0), "PlusJakartaSans"),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 0), (-1, -1), 11),
+ ("GRID", (0, 0), (0, 0), 0.5, colors.black),
+ ("GRID", (2, 0), (2, 0), 0.5, colors.black),
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
+ ]
+ )
+ )
+ elements.append(badges_table2)
+ elements.append(Spacer(1, 0.1 * inch))
+
+ # Dimensiones badges
+ if dimensiones:
+ if isinstance(dimensiones, str):
+ dim_list = [d.strip().lower() for d in dimensiones.split(",")]
+ else:
+ dim_list = [
+ d.lower() if isinstance(d, str) else str(d) for d in dimensiones
+ ]
+
+ dim_badges = []
+ for dim in dim_list:
+ if dim in DIMENSION_MAPPING:
+ abbrev, dim_color = DIMENSION_MAPPING[dim]
+ dim_badges.append((abbrev, dim_color))
+
+ if dim_badges:
+ dim_label = [["Dimensiones:"] + [b[0] for b in dim_badges]]
+ dim_widths = [1.2 * inch] + [0.4 * inch] * len(dim_badges)
+ dim_table = Table(dim_label, colWidths=dim_widths)
+
+ dim_styles = [
+ ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"),
+ ("FONTSIZE", (0, 0), (-1, -1), 11),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("LEFTPADDING", (0, 0), (-1, -1), 4),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 4),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+ ]
+ for idx, (_, dim_color) in enumerate(dim_badges):
+ col_idx = idx + 1
+ dim_styles.extend(
+ [
+ (
+ "BACKGROUND",
+ (col_idx, 0),
+ (col_idx, 0),
+ dim_color,
+ ),
+ ("TEXTCOLOR", (col_idx, 0), (col_idx, 0), COLOR_WHITE),
+ ("FONTNAME", (col_idx, 0), (col_idx, 0), "FiraCode"),
+ ("GRID", (col_idx, 0), (col_idx, 0), 0.5, colors.black),
+ ]
+ )
+
+ dim_table.setStyle(TableStyle(dim_styles))
+ elements.append(dim_table)
+ elements.append(Spacer(1, 0.15 * inch))
+
+ # Info table - use Paragraph for text wrapping
+ info_data = [
+ [
+ "DescripciΓ³n:",
+ Paragraph(descripcion, self.styles["normal_center"]),
+ ],
+ ["Marco:", marco],
+ [
+ "CategorΓa:",
+ Paragraph(categoria, self.styles["normal_center"]),
+ ],
+ ["ID Grupo Control:", id_grupo],
+ ]
+ info_table = Table(info_data, colWidths=[2 * inch, 4.5 * inch])
+ info_table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE),
+ ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (0, -1), "FiraCode"),
+ ("FONTSIZE", (0, 0), (0, -1), 10),
+ ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE),
+ ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY),
+ ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
+ ("FONTSIZE", (1, 0), (1, -1), 10),
+ ("ALIGN", (0, 0), (0, -1), "LEFT"),
+ ("ALIGN", (1, 0), (1, -1), "LEFT"),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY),
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+ ]
+ )
+ )
+ elements.append(info_table)
+ elements.append(Spacer(1, 0.3 * inch))
+
+ return elements
diff --git a/api/src/backend/tasks/jobs/reports/nis2.py b/api/src/backend/tasks/jobs/reports/nis2.py
new file mode 100644
index 0000000000..4ac5fa3d15
--- /dev/null
+++ b/api/src/backend/tasks/jobs/reports/nis2.py
@@ -0,0 +1,471 @@
+import os
+from collections import defaultdict
+
+from reportlab.lib.units import inch
+from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle
+
+from api.models import StatusChoices
+
+from .base import (
+ BaseComplianceReportGenerator,
+ ComplianceData,
+ get_requirement_metadata,
+)
+from .charts import create_horizontal_bar_chart, get_chart_color_for_percentage
+from .config import (
+ COLOR_BORDER_GRAY,
+ COLOR_DARK_GRAY,
+ COLOR_GRAY,
+ COLOR_GRID_GRAY,
+ COLOR_HIGH_RISK,
+ COLOR_NIS2_BG_BLUE,
+ COLOR_NIS2_PRIMARY,
+ COLOR_SAFE,
+ COLOR_WHITE,
+ NIS2_SECTION_TITLES,
+ NIS2_SECTIONS,
+)
+
+
+def _extract_section_number(section_string: str) -> str:
+ """Extract the section number from a full NIS2 section title.
+
+ NIS2 section strings are formatted like:
+ "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS..."
+
+ This function extracts just the leading number.
+
+ Args:
+ section_string: Full section title string.
+
+ Returns:
+ Section number as string (e.g., "1", "2", "11").
+ """
+ if not section_string:
+ return "Other"
+ parts = section_string.split()
+ if parts and parts[0].isdigit():
+ return parts[0]
+ return "Other"
+
+
+class NIS2ReportGenerator(BaseComplianceReportGenerator):
+ """
+ PDF report generator for NIS2 Directive (EU) 2022/2555.
+
+ This generator creates comprehensive PDF reports containing:
+ - Cover page with both Prowler and NIS2 logos
+ - Executive summary with overall compliance score
+ - Section analysis with horizontal bar chart
+ - SubSection breakdown table
+ - Critical failed requirements
+ - Requirements index organized by section and subsection
+ - Detailed findings for failed requirements
+ """
+
+ def create_cover_page(self, data: ComplianceData) -> list:
+ """
+ Create the NIS2 report cover page with both logos.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ # Create logos side by side
+ prowler_logo_path = os.path.join(
+ os.path.dirname(__file__), "../../assets/img/prowler_logo.png"
+ )
+ nis2_logo_path = os.path.join(
+ os.path.dirname(__file__), "../../assets/img/nis2_logo.png"
+ )
+
+ prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch)
+ nis2_logo = Image(nis2_logo_path, width=2.3 * inch, height=1.5 * inch)
+
+ logos_table = Table(
+ [[prowler_logo, nis2_logo]], colWidths=[4 * inch, 2.5 * inch]
+ )
+ logos_table.setStyle(
+ TableStyle(
+ [
+ ("ALIGN", (0, 0), (0, 0), "LEFT"),
+ ("ALIGN", (1, 0), (1, 0), "RIGHT"),
+ ("VALIGN", (0, 0), (0, 0), "MIDDLE"),
+ ("VALIGN", (1, 0), (1, 0), "MIDDLE"),
+ ]
+ )
+ )
+ elements.append(logos_table)
+ elements.append(Spacer(1, 0.3 * inch))
+
+ # Title
+ title = Paragraph(
+ "NIS2 Compliance Report
Directive (EU) 2022/2555",
+ self.styles["title"],
+ )
+ elements.append(title)
+ elements.append(Spacer(1, 0.3 * inch))
+
+ # Compliance metadata table - use base class helper for consistency
+ info_rows = self._build_info_rows(data, language="en")
+ # Convert tuples to lists and wrap long text in Paragraphs
+ metadata_data = []
+ for label, value in info_rows:
+ if label in ("Name:", "Description:") and value:
+ metadata_data.append(
+ [label, Paragraph(value, self.styles["normal_center"])]
+ )
+ else:
+ metadata_data.append([label, value])
+
+ metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch])
+ metadata_table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, -1), COLOR_NIS2_PRIMARY),
+ ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (0, -1), "FiraCode"),
+ ("BACKGROUND", (1, 0), (1, -1), COLOR_NIS2_BG_BLUE),
+ ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY),
+ ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
+ ("ALIGN", (0, 0), (-1, -1), "LEFT"),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ("FONTSIZE", (0, 0), (-1, -1), 11),
+ ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY),
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
+ ]
+ )
+ )
+ elements.append(metadata_table)
+
+ return elements
+
+ def create_executive_summary(self, data: ComplianceData) -> list:
+ """
+ Create the executive summary with compliance metrics.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ elements.append(Paragraph("Executive Summary", self.styles["h1"]))
+ elements.append(Spacer(1, 0.1 * inch))
+
+ # Calculate statistics
+ total = len(data.requirements)
+ passed = sum(1 for r in data.requirements if r.status == StatusChoices.PASS)
+ failed = sum(1 for r in data.requirements if r.status == StatusChoices.FAIL)
+ manual = sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL)
+
+ # Calculate compliance excluding manual
+ evaluated = passed + failed
+ overall_compliance = (passed / evaluated * 100) if evaluated > 0 else 100
+
+ # Summary statistics table
+ summary_data = [
+ ["Metric", "Value"],
+ ["Total Requirements", str(total)],
+ ["Passed β", str(passed)],
+ ["Failed β", str(failed)],
+ ["Manual β", str(manual)],
+ ["Overall Compliance", f"{overall_compliance:.1f}%"],
+ ]
+
+ summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch])
+ summary_table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY),
+ ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
+ ("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE),
+ ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE),
+ ("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK),
+ ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE),
+ ("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY),
+ ("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"),
+ ("FONTSIZE", (0, 0), (-1, 0), 12),
+ ("FONTSIZE", (0, 1), (-1, -1), 10),
+ ("BOTTOMPADDING", (0, 0), (-1, 0), 10),
+ ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY),
+ (
+ "ROWBACKGROUNDS",
+ (1, 1),
+ (1, -1),
+ [COLOR_WHITE, COLOR_NIS2_BG_BLUE],
+ ),
+ ]
+ )
+ )
+ elements.append(summary_table)
+
+ return elements
+
+ def create_charts_section(self, data: ComplianceData) -> list:
+ """
+ Create the charts section with section analysis.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ # Section chart
+ elements.append(Paragraph("Compliance by Section", self.styles["h1"]))
+ elements.append(Spacer(1, 0.1 * inch))
+ elements.append(
+ Paragraph(
+ "The following chart shows compliance percentage for each main section "
+ "of the NIS2 directive:",
+ self.styles["normal_center"],
+ )
+ )
+ elements.append(Spacer(1, 0.1 * inch))
+
+ chart_buffer = self._create_section_chart(data)
+ chart_buffer.seek(0)
+ chart_image = Image(chart_buffer, width=6.5 * inch, height=5 * inch)
+ elements.append(chart_image)
+ elements.append(PageBreak())
+
+ # SubSection breakdown table
+ elements.append(Paragraph("SubSection Breakdown", self.styles["h1"]))
+ elements.append(Spacer(1, 0.1 * inch))
+
+ subsection_table = self._create_subsection_table(data)
+ elements.append(subsection_table)
+
+ return elements
+
+ def create_requirements_index(self, data: ComplianceData) -> list:
+ """
+ Create the requirements index organized by section and subsection.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ elements.append(Paragraph("Requirements Index", self.styles["h1"]))
+ elements.append(Spacer(1, 0.1 * inch))
+
+ # Organize by section number and subsection
+ sections = {}
+ for req in data.requirements:
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ full_section = getattr(m, "Section", "Other")
+ # Extract section number from full title (e.g., "1 POLICY..." -> "1")
+ section_num = _extract_section_number(full_section)
+ subsection = getattr(m, "SubSection", "")
+ description = getattr(m, "Description", req.description)
+
+ if section_num not in sections:
+ sections[section_num] = {}
+ if subsection not in sections[section_num]:
+ sections[section_num][subsection] = []
+
+ sections[section_num][subsection].append(
+ {
+ "id": req.id,
+ "description": description,
+ "status": req.status,
+ }
+ )
+
+ # Sort by NIS2 section order
+ for section in NIS2_SECTIONS:
+ if section not in sections:
+ continue
+
+ section_title = NIS2_SECTION_TITLES.get(section, f"Section {section}")
+ elements.append(Paragraph(section_title, self.styles["h2"]))
+
+ for subsection_name, reqs in sections[section].items():
+ if subsection_name:
+ # Truncate long subsection names for display
+ display_subsection = (
+ subsection_name[:80] + "..."
+ if len(subsection_name) > 80
+ else subsection_name
+ )
+ elements.append(Paragraph(display_subsection, self.styles["h3"]))
+
+ for req in reqs:
+ status_indicator = (
+ "β" if req["status"] == StatusChoices.PASS else "β"
+ )
+ if req["status"] == StatusChoices.MANUAL:
+ status_indicator = "β"
+
+ desc = (
+ req["description"][:60] + "..."
+ if len(req["description"]) > 60
+ else req["description"]
+ )
+ elements.append(
+ Paragraph(
+ f"{status_indicator} {req['id']}: {desc}",
+ self.styles["normal"],
+ )
+ )
+
+ elements.append(Spacer(1, 0.1 * inch))
+
+ return elements
+
+ def _create_section_chart(self, data: ComplianceData):
+ """
+ Create the section compliance chart.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ BytesIO buffer containing the chart image.
+ """
+ section_scores = defaultdict(lambda: {"passed": 0, "total": 0})
+
+ for req in data.requirements:
+ if req.status == StatusChoices.MANUAL:
+ continue
+
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ full_section = getattr(m, "Section", "Other")
+ # Extract section number from full title (e.g., "1 POLICY..." -> "1")
+ section_num = _extract_section_number(full_section)
+ section_scores[section_num]["total"] += 1
+ if req.status == StatusChoices.PASS:
+ section_scores[section_num]["passed"] += 1
+
+ # Build labels and values in NIS2 section order
+ labels = []
+ values = []
+ for section in NIS2_SECTIONS:
+ if section in section_scores and section_scores[section]["total"] > 0:
+ scores = section_scores[section]
+ pct = (scores["passed"] / scores["total"]) * 100
+ section_title = NIS2_SECTION_TITLES.get(section, f"Section {section}")
+ labels.append(section_title)
+ values.append(pct)
+
+ return create_horizontal_bar_chart(
+ labels=labels,
+ values=values,
+ xlabel="Compliance (%)",
+ color_func=get_chart_color_for_percentage,
+ )
+
+ def _create_subsection_table(self, data: ComplianceData) -> Table:
+ """
+ Create the subsection breakdown table.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ ReportLab Table element.
+ """
+ subsection_scores = defaultdict(lambda: {"passed": 0, "failed": 0, "manual": 0})
+
+ for req in data.requirements:
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ full_section = getattr(m, "Section", "")
+ subsection = getattr(m, "SubSection", "")
+ # Use section number + subsection for grouping
+ section_num = _extract_section_number(full_section)
+ # Create a shorter key using section number
+ if subsection:
+ # Extract subsection number if present (e.g., "1.1 Policy..." -> "1.1")
+ subsection_parts = subsection.split()
+ if subsection_parts:
+ key = subsection_parts[0] # Just the number like "1.1"
+ else:
+ key = f"{section_num}"
+ else:
+ key = section_num
+
+ if req.status == StatusChoices.PASS:
+ subsection_scores[key]["passed"] += 1
+ elif req.status == StatusChoices.FAIL:
+ subsection_scores[key]["failed"] += 1
+ else:
+ subsection_scores[key]["manual"] += 1
+
+ table_data = [["Section", "Passed", "Failed", "Manual", "Compliance"]]
+ for key, scores in sorted(
+ subsection_scores.items(), key=lambda x: self._sort_section_key(x[0])
+ ):
+ total = scores["passed"] + scores["failed"]
+ pct = (scores["passed"] / total * 100) if total > 0 else 100
+ table_data.append(
+ [
+ key,
+ str(scores["passed"]),
+ str(scores["failed"]),
+ str(scores["manual"]),
+ f"{pct:.1f}%",
+ ]
+ )
+
+ table = Table(
+ table_data,
+ colWidths=[1.2 * inch, 0.9 * inch, 0.9 * inch, 0.9 * inch, 1.2 * inch],
+ )
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY),
+ ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
+ ("FONTSIZE", (0, 0), (-1, 0), 10),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("FONTSIZE", (0, 1), (-1, -1), 9),
+ ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
+ (
+ "ROWBACKGROUNDS",
+ (0, 1),
+ (-1, -1),
+ [COLOR_WHITE, COLOR_NIS2_BG_BLUE],
+ ),
+ ]
+ )
+ )
+
+ return table
+
+ def _sort_section_key(self, key: str) -> tuple:
+ """Sort section keys numerically (e.g., 1, 1.1, 1.2, 2, 11)."""
+ parts = key.split(".")
+ result = []
+ for part in parts:
+ try:
+ result.append(int(part))
+ except ValueError:
+ result.append(float("inf"))
+ return tuple(result)
diff --git a/api/src/backend/tasks/jobs/reports/threatscore.py b/api/src/backend/tasks/jobs/reports/threatscore.py
new file mode 100644
index 0000000000..e23085b1c3
--- /dev/null
+++ b/api/src/backend/tasks/jobs/reports/threatscore.py
@@ -0,0 +1,509 @@
+import gc
+
+from reportlab.lib import colors
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import inch
+from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle
+
+from api.models import StatusChoices
+
+from .base import (
+ BaseComplianceReportGenerator,
+ ComplianceData,
+ get_requirement_metadata,
+)
+from .charts import create_vertical_bar_chart, get_chart_color_for_percentage
+from .components import get_color_for_compliance, get_color_for_weight
+from .config import COLOR_HIGH_RISK, COLOR_WHITE
+
+
+class ThreatScoreReportGenerator(BaseComplianceReportGenerator):
+ """
+ PDF report generator for Prowler ThreatScore framework.
+
+ This generator creates comprehensive PDF reports containing:
+ - Compliance overview and metadata
+ - Section-by-section compliance scores with charts
+ - Overall ThreatScore calculation
+ - Critical failed requirements
+ - Detailed findings for each requirement
+ """
+
+ def create_executive_summary(self, data: ComplianceData) -> list:
+ """
+ Create the executive summary section with ThreatScore calculation.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ elements.append(Paragraph("Compliance Score by Sections", self.styles["h1"]))
+ elements.append(Spacer(1, 0.2 * inch))
+
+ # Create section score chart
+ chart_buffer = self._create_section_score_chart(data)
+ chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch)
+ elements.append(chart_image)
+
+ # Calculate overall ThreatScore
+ overall_compliance = self._calculate_threatscore(data)
+
+ elements.append(Spacer(1, 0.3 * inch))
+
+ # Summary table
+ summary_data = [["ThreatScore:", f"{overall_compliance:.2f}%"]]
+ compliance_color = get_color_for_compliance(overall_compliance)
+
+ summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch])
+ summary_table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)),
+ ("TEXTCOLOR", (0, 0), (0, 0), colors.white),
+ ("FONTNAME", (0, 0), (0, 0), "FiraCode"),
+ ("FONTSIZE", (0, 0), (0, 0), 12),
+ ("BACKGROUND", (1, 0), (1, 0), compliance_color),
+ ("TEXTCOLOR", (1, 0), (1, 0), colors.white),
+ ("FONTNAME", (1, 0), (1, 0), "FiraCode"),
+ ("FONTSIZE", (1, 0), (1, 0), 16),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)),
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
+ ("TOPPADDING", (0, 0), (-1, -1), 10),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
+ ]
+ )
+ )
+
+ elements.append(summary_table)
+
+ return elements
+
+ def _build_body_sections(self, data: ComplianceData) -> list:
+ """Override section order: Requirements Index before Critical Requirements."""
+ elements = []
+
+ # Page break to separate from executive summary
+ elements.append(PageBreak())
+
+ # Requirements index first
+ elements.extend(self.create_requirements_index(data))
+
+ # Critical requirements section (already starts with PageBreak internally)
+ elements.extend(self.create_charts_section(data))
+ elements.append(PageBreak())
+ gc.collect()
+
+ return elements
+
+ def create_charts_section(self, data: ComplianceData) -> list:
+ """
+ Create the critical failed requirements section.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+ min_risk_level = getattr(self, "_min_risk_level", 4)
+
+ # Start on a new page
+ elements.append(PageBreak())
+ elements.append(
+ Paragraph("Top Requirements by Level of Risk", self.styles["h1"])
+ )
+ elements.append(Spacer(1, 0.1 * inch))
+ elements.append(
+ Paragraph(
+ f"Critical Failed Requirements (Risk Level β₯ {min_risk_level})",
+ self.styles["h2"],
+ )
+ )
+ elements.append(Spacer(1, 0.2 * inch))
+
+ critical_failed = self._get_critical_failed_requirements(data, min_risk_level)
+
+ if not critical_failed:
+ elements.append(
+ Paragraph(
+ "β
No critical failed requirements found. Great job!",
+ self.styles["normal"],
+ )
+ )
+ else:
+ elements.append(
+ Paragraph(
+ f"Found {len(critical_failed)} critical failed requirements "
+ "that require immediate attention:",
+ self.styles["normal"],
+ )
+ )
+ elements.append(Spacer(1, 0.5 * inch))
+
+ table = self._create_critical_requirements_table(critical_failed)
+ elements.append(table)
+
+ # Immediate action required banner
+ elements.append(Spacer(1, 0.3 * inch))
+ elements.append(self._create_action_required_banner())
+
+ return elements
+
+ def create_requirements_index(self, data: ComplianceData) -> list:
+ """
+ Create the requirements index organized by section and subsection.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ List of ReportLab elements.
+ """
+ elements = []
+
+ elements.append(Paragraph("Requirements Index", self.styles["h1"]))
+
+ # Organize requirements by section and subsection
+ sections = {}
+ for req_id in data.attributes_by_requirement_id:
+ m = get_requirement_metadata(req_id, data.attributes_by_requirement_id)
+ if m:
+ section = getattr(m, "Section", "N/A")
+ subsection = getattr(m, "SubSection", "N/A")
+ title = getattr(m, "Title", "N/A")
+
+ if section not in sections:
+ sections[section] = {}
+ if subsection not in sections[section]:
+ sections[section][subsection] = []
+
+ sections[section][subsection].append({"id": req_id, "title": title})
+
+ section_num = 1
+ for section_name, subsections in sections.items():
+ elements.append(
+ Paragraph(f"{section_num}. {section_name}", self.styles["h2"])
+ )
+
+ for subsection_name, requirements in subsections.items():
+ elements.append(Paragraph(f"{subsection_name}", self.styles["h3"]))
+
+ for req in requirements:
+ elements.append(
+ Paragraph(
+ f"{req['id']} - {req['title']}", self.styles["normal"]
+ )
+ )
+
+ section_num += 1
+ elements.append(Spacer(1, 0.1 * inch))
+
+ return elements
+
+ def _create_section_score_chart(self, data: ComplianceData):
+ """
+ Create the section compliance score chart using weighted ThreatScore formula.
+
+ The section score uses the same weighted formula as the overall ThreatScore:
+ Score = Ξ£(rate_i * total_findings_i * weight_i * rfac_i) / Ξ£(total_findings_i * weight_i * rfac_i)
+ Where rfac_i = 1 + 0.25 * risk_level
+
+ Sections without findings are shown with 100% score.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ BytesIO buffer containing the chart image.
+ """
+ # First, collect ALL sections from requirements (including those without findings)
+ all_sections = set()
+ sections_data = {}
+
+ for req in data.requirements:
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+ if m:
+ section = getattr(m, "Section", "Other")
+ all_sections.add(section)
+
+ # Only calculate scores for requirements with findings
+ if req.total_findings == 0:
+ continue
+
+ risk_level_raw = getattr(m, "LevelOfRisk", 0)
+ weight_raw = getattr(m, "Weight", 0)
+ # Ensure numeric types for calculations (compliance data may have str)
+ try:
+ risk_level = int(risk_level_raw) if risk_level_raw else 0
+ except (ValueError, TypeError):
+ risk_level = 0
+ try:
+ weight = int(weight_raw) if weight_raw else 0
+ except (ValueError, TypeError):
+ weight = 0
+
+ # ThreatScore formula components
+ rate_i = req.passed_findings / req.total_findings
+ rfac_i = 1 + 0.25 * risk_level
+
+ if section not in sections_data:
+ sections_data[section] = {
+ "numerator": 0,
+ "denominator": 0,
+ }
+
+ sections_data[section]["numerator"] += (
+ rate_i * req.total_findings * weight * rfac_i
+ )
+ sections_data[section]["denominator"] += (
+ req.total_findings * weight * rfac_i
+ )
+
+ # Calculate percentages for all sections
+ labels = []
+ values = []
+ for section in sorted(all_sections):
+ if section in sections_data and sections_data[section]["denominator"] > 0:
+ pct = (
+ sections_data[section]["numerator"]
+ / sections_data[section]["denominator"]
+ ) * 100
+ else:
+ # Sections without findings get 100%
+ pct = 100.0
+ labels.append(section)
+ values.append(pct)
+
+ return create_vertical_bar_chart(
+ labels=labels,
+ values=values,
+ ylabel="Compliance Score (%)",
+ xlabel="",
+ color_func=get_chart_color_for_percentage,
+ rotation=0,
+ )
+
+ def _calculate_threatscore(self, data: ComplianceData) -> float:
+ """
+ Calculate the overall ThreatScore using the weighted formula.
+
+ Args:
+ data: Aggregated compliance data.
+
+ Returns:
+ Overall ThreatScore percentage.
+ """
+ numerator = 0
+ denominator = 0
+ has_findings = False
+
+ for req in data.requirements:
+ if req.total_findings == 0:
+ continue
+
+ has_findings = True
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+
+ if m:
+ risk_level_raw = getattr(m, "LevelOfRisk", 0)
+ weight_raw = getattr(m, "Weight", 0)
+ # Ensure numeric types for calculations (compliance data may have str)
+ try:
+ risk_level = int(risk_level_raw) if risk_level_raw else 0
+ except (ValueError, TypeError):
+ risk_level = 0
+ try:
+ weight = int(weight_raw) if weight_raw else 0
+ except (ValueError, TypeError):
+ weight = 0
+
+ rate_i = req.passed_findings / req.total_findings
+ rfac_i = 1 + 0.25 * risk_level
+
+ numerator += rate_i * req.total_findings * weight * rfac_i
+ denominator += req.total_findings * weight * rfac_i
+
+ if not has_findings:
+ return 100.0
+ if denominator > 0:
+ return (numerator / denominator) * 100
+ return 0.0
+
+ def _get_critical_failed_requirements(
+ self, data: ComplianceData, min_risk_level: int
+ ) -> list[dict]:
+ """
+ Get critical failed requirements sorted by risk level and weight.
+
+ Args:
+ data: Aggregated compliance data.
+ min_risk_level: Minimum risk level threshold.
+
+ Returns:
+ List of critical failed requirement dictionaries.
+ """
+ critical = []
+
+ for req in data.requirements:
+ if req.status != StatusChoices.FAIL:
+ continue
+
+ m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
+
+ if m:
+ risk_level_raw = getattr(m, "LevelOfRisk", 0)
+ weight_raw = getattr(m, "Weight", 0)
+ # Ensure numeric types for calculations (compliance data may have str)
+ try:
+ risk_level = int(risk_level_raw) if risk_level_raw else 0
+ except (ValueError, TypeError):
+ risk_level = 0
+ try:
+ weight = int(weight_raw) if weight_raw else 0
+ except (ValueError, TypeError):
+ weight = 0
+
+ if risk_level >= min_risk_level:
+ critical.append(
+ {
+ "id": req.id,
+ "risk_level": risk_level,
+ "weight": weight,
+ "title": getattr(m, "Title", "N/A"),
+ "section": getattr(m, "Section", "N/A"),
+ }
+ )
+
+ critical.sort(key=lambda x: (x["risk_level"], x["weight"]), reverse=True)
+ return critical
+
+ def _create_critical_requirements_table(self, critical_requirements: list) -> Table:
+ """
+ Create the critical requirements table.
+
+ Args:
+ critical_requirements: List of critical requirement dictionaries.
+
+ Returns:
+ ReportLab Table element.
+ """
+ table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]]
+
+ for req in critical_requirements:
+ title = req["title"]
+ if len(title) > 50:
+ title = title[:47] + "..."
+
+ table_data.append(
+ [
+ str(req["risk_level"]),
+ str(req["weight"]),
+ req["id"],
+ title,
+ req["section"],
+ ]
+ )
+
+ table = Table(
+ table_data,
+ colWidths=[0.7 * inch, 0.9 * inch, 1.3 * inch, 3.1 * inch, 1.5 * inch],
+ )
+
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (0, 0), (-1, 0), COLOR_HIGH_RISK),
+ ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
+ ("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
+ ("FONTSIZE", (0, 0), (-1, 0), 10),
+ ("BACKGROUND", (0, 1), (0, -1), COLOR_HIGH_RISK),
+ ("TEXTCOLOR", (0, 1), (0, -1), COLOR_WHITE),
+ ("FONTNAME", (0, 1), (0, -1), "FiraCode"),
+ ("ALIGN", (0, 1), (0, -1), "CENTER"),
+ ("FONTSIZE", (0, 1), (0, -1), 12),
+ ("ALIGN", (1, 1), (1, -1), "CENTER"),
+ ("FONTNAME", (1, 1), (1, -1), "FiraCode"),
+ ("FONTNAME", (2, 1), (2, -1), "FiraCode"),
+ ("FONTSIZE", (2, 1), (2, -1), 9),
+ ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"),
+ ("FONTSIZE", (3, 1), (-1, -1), 8),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)),
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
+ ("BACKGROUND", (1, 1), (-1, -1), colors.Color(0.98, 0.98, 0.98)),
+ ]
+ )
+ )
+
+ # Color weight column based on value
+ for idx, req in enumerate(critical_requirements):
+ row_idx = idx + 1
+ weight_color = get_color_for_weight(req["weight"])
+ table.setStyle(
+ TableStyle(
+ [
+ ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color),
+ ("TEXTCOLOR", (1, row_idx), (1, row_idx), COLOR_WHITE),
+ ]
+ )
+ )
+
+ return table
+
+ def _create_action_required_banner(self) -> Table:
+ """
+ Create the 'Immediate Action Required' banner for critical requirements.
+
+ Returns:
+ ReportLab Table element styled as a red-bordered alert banner.
+ """
+ banner_style = ParagraphStyle(
+ "ActionRequired",
+ fontName="PlusJakartaSans",
+ fontSize=11,
+ textColor=COLOR_HIGH_RISK,
+ leading=16,
+ )
+
+ banner_content = Paragraph(
+ "IMMEDIATE ACTION REQUIRED:
"
+ "These requirements have the highest risk levels and have failed "
+ "compliance checks. Please prioritize addressing these issues to "
+ "improve your security posture.",
+ banner_style,
+ )
+
+ banner_table = Table(
+ [[banner_content]],
+ colWidths=[6.5 * inch],
+ )
+ banner_table.setStyle(
+ TableStyle(
+ [
+ (
+ "BACKGROUND",
+ (0, 0),
+ (0, 0),
+ colors.Color(0.98, 0.92, 0.92),
+ ),
+ ("BOX", (0, 0), (0, 0), 2, COLOR_HIGH_RISK),
+ ("LEFTPADDING", (0, 0), (0, 0), 20),
+ ("RIGHTPADDING", (0, 0), (0, 0), 20),
+ ("TOPPADDING", (0, 0), (0, 0), 15),
+ ("BOTTOMPADDING", (0, 0), (0, 0), 15),
+ ]
+ )
+ )
+
+ return banner_table
diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py
index 9e40e2df03..9697359065 100644
--- a/api/src/backend/tasks/jobs/scan.py
+++ b/api/src/backend/tasks/jobs/scan.py
@@ -45,6 +45,7 @@ from api.models import (
ResourceTag,
Scan,
ScanCategorySummary,
+ ScanGroupSummary,
ScanSummary,
StateChoices,
)
@@ -127,6 +128,50 @@ def aggregate_category_counts(
cache[key]["new_failed"] += 1
+def aggregate_resource_group_counts(
+ resource_group: str | None,
+ severity: str,
+ status: str,
+ delta: str | None,
+ muted: bool,
+ resource_uid: str,
+ cache: dict[tuple[str, str], dict[str, int]],
+ group_resources_cache: dict[str, set],
+) -> None:
+ """
+ Increment resource group counters in-place for a finding.
+
+ Args:
+ resource_group: Resource group from check metadata (e.g., "database", "compute").
+ severity: Severity level (e.g., "high", "medium").
+ status: Finding status as string ("FAIL", "PASS").
+ delta: Delta value as string ("new", "changed") or None.
+ muted: Whether the finding is muted.
+ resource_uid: Unique identifier for the resource to count distinct resources.
+ cache: Dict {(resource_group, severity): {"total", "failed", "new_failed"}} to update.
+ group_resources_cache: Dict {resource_group: set(resource_uids)} for group-level resource tracking.
+ """
+ if not resource_group:
+ return
+
+ is_failed = status == "FAIL" and not muted
+ is_new_failed = is_failed and delta == "new"
+
+ key = (resource_group, severity)
+ if key not in cache:
+ cache[key] = {"total": 0, "failed": 0, "new_failed": 0}
+ if not muted:
+ cache[key]["total"] += 1
+ if is_failed:
+ cache[key]["failed"] += 1
+ if is_new_failed:
+ cache[key]["new_failed"] += 1
+
+ # Track resources at GROUP level (not per-severity) to avoid over-counting
+ if resource_uid and not muted:
+ group_resources_cache.setdefault(resource_group, set()).add(resource_uid)
+
+
def _get_attack_surface_mapping_from_provider(provider_type: str) -> dict:
global _ATTACK_SURFACE_MAPPING_CACHE
@@ -438,6 +483,8 @@ def _process_finding_micro_batch(
scan_resource_cache: set,
mute_rules_cache: dict,
scan_categories_cache: dict[tuple[str, str], dict[str, int]],
+ scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]],
+ group_resources_cache: dict[str, set],
) -> None:
"""
Process a micro-batch of findings and persist them using bulk operations.
@@ -459,6 +506,8 @@ def _process_finding_micro_batch(
scan_resource_cache: Set of tuples used to create `ResourceScanSummary` rows.
mute_rules_cache: Map of finding UID -> mute reason gathered before the scan.
scan_categories_cache: Dict tracking category counts {(category, severity): {"total", "failed", "new_failed"}}.
+ scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}.
+ group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}.
"""
# Accumulate objects for bulk operations
findings_to_create = []
@@ -499,6 +548,8 @@ def _process_finding_micro_batch(
with rls_transaction(tenant_id):
resource_uid = finding.resource_uid
if resource_uid not in resource_cache:
+ check_metadata = finding.get_metadata()
+ group = check_metadata.get("resourcegroup") or None
resource_instance, _ = Resource.objects.get_or_create(
tenant_id=tenant_id,
provider=provider_instance,
@@ -508,6 +559,7 @@ def _process_finding_micro_batch(
"service": finding.service_name,
"type": finding.resource_type,
"name": finding.resource_name,
+ "groups": [group] if group else None,
},
)
resource_cache[resource_uid] = resource_instance
@@ -528,6 +580,8 @@ def _process_finding_micro_batch(
# Track resource field changes (defer save)
updated = False
+ check_metadata = finding.get_metadata()
+ group = check_metadata.get("resourcegroup") or None
if finding.region and resource_instance.region != finding.region:
resource_instance.region = finding.region
updated = True
@@ -548,6 +602,11 @@ def _process_finding_micro_batch(
if resource_instance.partition != finding.partition:
resource_instance.partition = finding.partition
updated = True
+ if group and (
+ not resource_instance.groups or group not in resource_instance.groups
+ ):
+ resource_instance.groups = (resource_instance.groups or []) + [group]
+ updated = True
if updated:
dirty_resources[resource_uid] = resource_instance
@@ -633,6 +692,7 @@ def _process_finding_micro_batch(
muted_reason=muted_reason,
compliance=finding.compliance,
categories=check_metadata.get("categories", []) or [],
+ resource_groups=check_metadata.get("resourcegroup") or None,
)
findings_to_create.append(finding_instance)
resource_denormalized_data.append((finding_instance, resource_instance))
@@ -657,6 +717,18 @@ def _process_finding_micro_batch(
cache=scan_categories_cache,
)
+ # Track resource groups with counts for ScanGroupSummary
+ aggregate_resource_group_counts(
+ resource_group=check_metadata.get("resourcegroup") or None,
+ severity=finding.severity.value,
+ status=status.value,
+ delta=delta.value if delta else None,
+ muted=is_muted,
+ resource_uid=resource_instance.uid if resource_instance else "",
+ cache=scan_resource_groups_cache,
+ group_resources_cache=group_resources_cache,
+ )
+
# Bulk operations within single transaction
with rls_transaction(tenant_id):
# Bulk create findings
@@ -714,7 +786,15 @@ def _process_finding_micro_batch(
tenant_id=tenant_id,
model=Resource,
objects=list(dirty_resources.values()),
- fields=["metadata", "details", "partition", "region", "service", "type"],
+ fields=[
+ "metadata",
+ "details",
+ "partition",
+ "region",
+ "service",
+ "type",
+ "groups",
+ ],
batch_size=1000,
)
@@ -757,6 +837,8 @@ def perform_prowler_scan(
unique_resources = set()
scan_resource_cache: set[tuple[str, str, str, str]] = set()
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
+ scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
+ group_resources_cache: dict[str, set] = {}
start_time = time.time()
exc = None
@@ -847,6 +929,8 @@ def perform_prowler_scan(
scan_resource_cache=scan_resource_cache,
mute_rules_cache=mute_rules_cache,
scan_categories_cache=scan_categories_cache,
+ scan_resource_groups_cache=scan_resource_groups_cache,
+ group_resources_cache=group_resources_cache,
)
# Update scan progress
@@ -933,6 +1017,38 @@ def perform_prowler_scan(
sentry_sdk.capture_exception(cat_exception)
logger.error(f"Error storing categories for scan {scan_id}: {cat_exception}")
+ try:
+ if scan_resource_groups_cache:
+ # Compute group-level resource counts (same value for all severity rows in a group)
+ group_resource_counts = {
+ grp: len(uids) for grp, uids in group_resources_cache.items()
+ }
+ resource_group_summaries = [
+ ScanGroupSummary(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ resource_group=grp,
+ severity=severity,
+ total_findings=counts["total"],
+ failed_findings=counts["failed"],
+ new_failed_findings=counts["new_failed"],
+ resources_count=group_resource_counts.get(grp, 0),
+ )
+ for (
+ grp,
+ severity,
+ ), counts in scan_resource_groups_cache.items()
+ ]
+ with rls_transaction(tenant_id):
+ ScanGroupSummary.objects.bulk_create(
+ resource_group_summaries, batch_size=500, ignore_conflicts=True
+ )
+ except Exception as rg_exception:
+ sentry_sdk.capture_exception(rg_exception)
+ logger.error(
+ f"Error storing resource groups for scan {scan_id}: {rg_exception}"
+ )
+
serializer = ScanTaskSerializer(instance=scan_instance)
return serializer.data
diff --git a/api/src/backend/tasks/jobs/threatscore.py b/api/src/backend/tasks/jobs/threatscore.py
index 414f2d20f2..a9a7516e55 100644
--- a/api/src/backend/tasks/jobs/threatscore.py
+++ b/api/src/backend/tasks/jobs/threatscore.py
@@ -131,9 +131,11 @@ def compute_threatscore_metrics(
continue
m = metadata[0]
- risk_level = getattr(m, "LevelOfRisk", 0)
- weight = getattr(m, "Weight", 0)
+ risk_level_raw = getattr(m, "LevelOfRisk", 0)
+ weight_raw = getattr(m, "Weight", 0)
section = getattr(m, "Section", "Unknown")
+ risk_level = int(risk_level_raw) if risk_level_raw else 0
+ weight = int(weight_raw) if weight_raw else 0
# Calculate ThreatScore components using formula from UI
rate_i = req_passed_findings / req_total_findings
diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py
index 78adb7842b..c46c279bf4 100644
--- a/api/src/backend/tasks/jobs/threatscore_utils.py
+++ b/api/src/backend/tasks/jobs/threatscore_utils.py
@@ -1,9 +1,6 @@
-from collections import defaultdict
-
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from django.db.models import Count, Q
-from tasks.utils import batched
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
@@ -154,6 +151,12 @@ def _load_findings_for_requirement_checks(
Supports optional caching to avoid duplicate queries when generating multiple
reports for the same scan.
+ Memory optimizations:
+ - Uses database iterator with chunk_size for streaming large result sets
+ - Shares references between cache and return dict (no duplication)
+ - Only selects required fields from database
+ - Processes findings in batches to reduce memory pressure
+
Args:
tenant_id (str): The tenant ID for Row-Level Security context.
scan_id (str): The ID of the scan to retrieve findings for.
@@ -171,69 +174,73 @@ def _load_findings_for_requirement_checks(
'aws_s3_bucket_public_access': [FindingOutput(...)]
}
"""
- findings_by_check_id = defaultdict(list)
-
if not check_ids:
- return dict(findings_by_check_id)
+ return {}
# Initialize cache if not provided
if findings_cache is None:
findings_cache = {}
+ # Deduplicate check_ids to avoid redundant processing
+ unique_check_ids = list(set(check_ids))
+
# Separate cached and non-cached check_ids
check_ids_to_load = []
cache_hits = 0
- cache_misses = 0
- for check_id in check_ids:
+ for check_id in unique_check_ids:
if check_id in findings_cache:
- # Reuse from cache
- findings_by_check_id[check_id] = findings_cache[check_id]
cache_hits += 1
else:
- # Need to load from database
check_ids_to_load.append(check_id)
- cache_misses += 1
if cache_hits > 0:
+ total_checks = len(unique_check_ids)
logger.info(
- f"Findings cache: {cache_hits} hits, {cache_misses} misses "
- f"({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)"
+ f"Findings cache: {cache_hits}/{total_checks} hits "
+ f"({cache_hits / total_checks * 100:.1f}% hit rate)"
)
- # If all check_ids were in cache, return early
- if not check_ids_to_load:
- return dict(findings_by_check_id)
-
- logger.info(f"Loading findings for {len(check_ids_to_load)} checks on-demand")
-
- findings_queryset = (
- Finding.all_objects.filter(
- tenant_id=tenant_id, scan_id=scan_id, check_id__in=check_ids_to_load
+ # Load missing check_ids from database
+ if check_ids_to_load:
+ logger.info(
+ f"Loading findings for {len(check_ids_to_load)} checks from database"
)
- .order_by("uid")
- .iterator()
- )
- with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
- for batch, is_last_batch in batched(
- findings_queryset, DJANGO_FINDINGS_BATCH_SIZE
- ):
- for finding_model in batch:
+ with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
+ # Use iterator with chunk_size for memory-efficient streaming
+ # chunk_size controls how many rows Django fetches from DB at once
+ findings_queryset = (
+ Finding.all_objects.filter(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ check_id__in=check_ids_to_load,
+ )
+ .order_by("check_id", "uid")
+ .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
+ )
+
+ # Pre-initialize empty lists for all check_ids to load
+ # This avoids repeated dict lookups and 'if not in' checks
+ for check_id in check_ids_to_load:
+ findings_cache[check_id] = []
+
+ findings_count = 0
+ for finding_model in findings_queryset:
finding_output = FindingOutput.transform_api_finding(
finding_model, prowler_provider
)
- findings_by_check_id[finding_output.check_id].append(finding_output)
- # Update cache with newly loaded findings
- if finding_output.check_id not in findings_cache:
- findings_cache[finding_output.check_id] = []
findings_cache[finding_output.check_id].append(finding_output)
+ findings_count += 1
- total_findings_loaded = sum(
- len(findings) for findings in findings_by_check_id.values()
- )
- logger.info(
- f"Loaded {total_findings_loaded} findings for {len(findings_by_check_id)} checks"
- )
+ logger.info(
+ f"Loaded {findings_count} findings for {len(check_ids_to_load)} checks"
+ )
- return dict(findings_by_check_id)
+ # Build result dict using cache references (no data duplication)
+ # This shares the same list objects between cache and result
+ result = {
+ check_id: findings_cache.get(check_id, []) for check_id in unique_check_ids
+ }
+
+ return result
diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py
index 542f01ef65..0dd3b0905b 100644
--- a/api/src/backend/tasks/tasks.py
+++ b/api/src/backend/tasks/tasks.py
@@ -8,12 +8,17 @@ from celery.utils.log import get_task_logger
from config.celery import RLSTask
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY
from django_celery_beat.models import PeriodicTask
+from tasks.jobs.attack_paths import (
+ attack_paths_scan,
+ can_provider_run_attack_paths_scan,
+)
from tasks.jobs.backfill import (
backfill_compliance_summaries,
backfill_daily_severity_summaries,
backfill_provider_compliance_scores,
backfill_resource_scan_summaries,
backfill_scan_category_summaries,
+ backfill_scan_resource_group_summaries,
)
from tasks.jobs.connection import (
check_integration_connection,
@@ -47,7 +52,11 @@ from tasks.jobs.scan import (
perform_prowler_scan,
update_provider_compliance_scores,
)
-from tasks.utils import batched, get_next_execution_datetime
+from tasks.utils import (
+ _get_or_create_scheduled_scan,
+ batched,
+ get_next_execution_datetime,
+)
from api.compliance import get_compliance_frameworks
from api.db_router import READ_REPLICA_ALIAS
@@ -152,6 +161,11 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str)
),
).apply_async()
+ if can_provider_run_attack_paths_scan(tenant_id, provider_id):
+ perform_attack_paths_scan_task.apply_async(
+ kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
+ )
+
@shared_task(base=RLSTask, name="provider-connection-check")
@set_tenant
@@ -264,44 +278,38 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
periodic_task_instance = PeriodicTask.objects.get(
name=f"scan-perform-scheduled-{provider_id}"
)
-
- executed_scan = Scan.objects.filter(
- tenant_id=tenant_id,
- provider_id=provider_id,
- task__task_runner_task__task_id=task_id,
- ).order_by("completed_at")
-
- if (
+ executing_scan = (
Scan.objects.filter(
tenant_id=tenant_id,
provider_id=provider_id,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.EXECUTING,
- scheduler_task_id=periodic_task_instance.id,
- scheduled_at__date=datetime.now(timezone.utc).date(),
- ).exists()
- or executed_scan.exists()
- ):
- # Duplicated task execution due to visibility timeout or scan is already running
- logger.warning(f"Duplicated scheduled scan for provider {provider_id}.")
- try:
- affected_scan = executed_scan.first()
- if not affected_scan:
- raise ValueError(
- "Error retrieving affected scan details after detecting duplicated scheduled "
- "scan."
- )
- # Return the affected scan details to avoid losing data
- serializer = ScanTaskSerializer(instance=affected_scan)
- except Exception as duplicated_scan_exception:
- logger.error(
- f"Duplicated scheduled scan for provider {provider_id}. Error retrieving affected scan details: "
- f"{str(duplicated_scan_exception)}"
- )
- raise duplicated_scan_exception
- return serializer.data
+ )
+ .order_by("-started_at")
+ .first()
+ )
+ if executing_scan:
+ logger.warning(
+ f"Scheduled scan already executing for provider {provider_id}. Skipping."
+ )
+ return ScanTaskSerializer(instance=executing_scan).data
+ executed_scan = Scan.objects.filter(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ task__task_runner_task__task_id=task_id,
+ ).first()
+
+ if executed_scan:
+ # Duplicated task execution due to visibility timeout
+ logger.warning(f"Duplicated scheduled scan for provider {provider_id}.")
+ return ScanTaskSerializer(instance=executed_scan).data
+
+ interval = periodic_task_instance.interval
next_scan_datetime = get_next_execution_datetime(task_id, provider_id)
+ current_scan_datetime = next_scan_datetime - timedelta(
+ **{interval.period: interval.every}
+ )
# TEMPORARY WORKAROUND: Clean up orphan scans from transaction isolation issue
_cleanup_orphan_scheduled_scans(
@@ -310,19 +318,12 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
scheduler_task_id=periodic_task_instance.id,
)
- scan_instance, _ = Scan.objects.get_or_create(
+ scan_instance = _get_or_create_scheduled_scan(
tenant_id=tenant_id,
provider_id=provider_id,
- trigger=Scan.TriggerChoices.SCHEDULED,
- state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE),
scheduler_task_id=periodic_task_instance.id,
- defaults={
- "state": StateChoices.SCHEDULED,
- "name": "Daily scheduled scan",
- "scheduled_at": next_scan_datetime - timedelta(days=1),
- },
+ scheduled_at=current_scan_datetime,
)
-
scan_instance.task_id = task_id
scan_instance.save()
@@ -332,18 +333,19 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
scan_id=str(scan_instance.id),
provider_id=provider_id,
)
- except Exception as e:
- raise e
finally:
with rls_transaction(tenant_id):
- Scan.objects.get_or_create(
+ now = datetime.now(timezone.utc)
+ if next_scan_datetime <= now:
+ interval_delta = timedelta(**{interval.period: interval.every})
+ while next_scan_datetime <= now:
+ next_scan_datetime += interval_delta
+ _get_or_create_scheduled_scan(
tenant_id=tenant_id,
- name="Daily scheduled scan",
provider_id=provider_id,
- trigger=Scan.TriggerChoices.SCHEDULED,
- state=StateChoices.SCHEDULED,
- scheduled_at=next_scan_datetime,
scheduler_task_id=periodic_task_instance.id,
+ scheduled_at=next_scan_datetime,
+ update_state=True,
)
_perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
@@ -357,6 +359,29 @@ def perform_scan_summary_task(tenant_id: str, scan_id: str):
return aggregate_findings(tenant_id=tenant_id, scan_id=scan_id)
+@shared_task(
+ base=RLSTask,
+ bind=True,
+ name="attack-paths-scan-perform",
+ queue="attack-paths-scans",
+)
+def perform_attack_paths_scan_task(self, tenant_id: str, scan_id: str):
+ """
+ Execute an Attack Paths scan for the given provider within the current tenant RLS context.
+
+ Args:
+ self: The task instance (automatically passed when bind=True).
+ tenant_id (str): The tenant identifier for RLS context.
+ scan_id (str): The Prowler scan identifier for obtaining the tenant and provider context.
+
+ Returns:
+ Any: The result from `attack_paths_scan`, including any per-scan failure details.
+ """
+ return attack_paths_scan(
+ tenant_id=tenant_id, scan_id=scan_id, task_id=self.request.id
+ )
+
+
@shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,))
def delete_tenant_task(tenant_id: str):
return delete_tenant(pk=tenant_id)
@@ -613,6 +638,21 @@ def backfill_scan_category_summaries_task(tenant_id: str, scan_id: str):
return backfill_scan_category_summaries(tenant_id=tenant_id, scan_id=scan_id)
+@shared_task(name="backfill-scan-resource-group-summaries", queue="backfill")
+@handle_provider_deletion
+def backfill_scan_resource_group_summaries_task(tenant_id: str, scan_id: str):
+ """
+ Backfill ScanGroupSummary for a completed scan.
+
+ Aggregates unique resource groups from findings and creates a summary row.
+
+ Args:
+ tenant_id (str): The tenant identifier.
+ scan_id (str): The scan identifier.
+ """
+ return backfill_scan_resource_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
+
+
@shared_task(name="backfill-provider-compliance-scores", queue="backfill")
def backfill_provider_compliance_scores_task(tenant_id: str):
"""
diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py
new file mode 100644
index 0000000000..0309e7162a
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py
@@ -0,0 +1,708 @@
+from contextlib import nullcontext
+from types import SimpleNamespace
+from unittest.mock import MagicMock, call, patch
+
+import pytest
+from tasks.jobs.attack_paths import prowler as prowler_module
+from tasks.jobs.attack_paths.scan import run as attack_paths_run
+
+from api.models import (
+ AttackPathsScan,
+ Finding,
+ Provider,
+ Resource,
+ ResourceFindingMapping,
+ Scan,
+ StateChoices,
+ StatusChoices,
+)
+from prowler.lib.check.models import Severity
+
+
+@pytest.mark.django_db
+class TestAttackPathsRun:
+ def test_run_success_flow(self, tenants_fixture, providers_fixture, scans_fixture):
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+ scan = scans_fixture[0]
+ scan.provider = provider
+ scan.save()
+
+ attack_paths_scan = AttackPathsScan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ scan=scan,
+ state=StateChoices.SCHEDULED,
+ )
+
+ mock_session = MagicMock()
+ session_ctx = MagicMock()
+ session_ctx.__enter__.return_value = mock_session
+ session_ctx.__exit__.return_value = False
+ ingestion_result = {"organizations": "warning"}
+ ingestion_fn = MagicMock(return_value=ingestion_result)
+
+ with (
+ patch(
+ "tasks.jobs.attack_paths.scan.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.initialize_prowler_provider",
+ return_value=MagicMock(_enabled_regions=["us-east-1"]),
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.graph_database.get_uri",
+ return_value="bolt://neo4j",
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.graph_database.get_database_name",
+ return_value="db-scan-id",
+ ) as mock_get_db_name,
+ patch(
+ "tasks.jobs.attack_paths.scan.graph_database.create_database"
+ ) as mock_create_db,
+ patch(
+ "tasks.jobs.attack_paths.scan.graph_database.get_session",
+ return_value=session_ctx,
+ ) as mock_get_session,
+ patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache"),
+ patch(
+ "tasks.jobs.attack_paths.scan.cartography_create_indexes.run"
+ ) as mock_cartography_indexes,
+ patch(
+ "tasks.jobs.attack_paths.scan.cartography_analysis.run"
+ ) as mock_cartography_analysis,
+ patch(
+ "tasks.jobs.attack_paths.scan.cartography_ontology.run"
+ ) as mock_cartography_ontology,
+ patch(
+ "tasks.jobs.attack_paths.scan.prowler.create_indexes"
+ ) as mock_prowler_indexes,
+ patch(
+ "tasks.jobs.attack_paths.scan.prowler.analysis"
+ ) as mock_prowler_analysis,
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
+ return_value=attack_paths_scan,
+ ) as mock_retrieve_scan,
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"
+ ) as mock_starting,
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress"
+ ) as mock_update_progress,
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan"
+ ) as mock_finish,
+ patch(
+ "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
+ return_value=ingestion_fn,
+ ) as mock_get_ingestion,
+ patch(
+ "tasks.jobs.attack_paths.scan._call_within_event_loop",
+ side_effect=lambda fn, *a, **kw: fn(*a, **kw),
+ ) as mock_event_loop,
+ ):
+ result = attack_paths_run(str(tenant.id), str(scan.id), "task-123")
+
+ assert result == ingestion_result
+ mock_retrieve_scan.assert_called_once_with(str(tenant.id), str(scan.id))
+ mock_starting.assert_called_once()
+ config = mock_starting.call_args[0][2]
+ assert config.neo4j_database == "db-scan-id"
+
+ mock_create_db.assert_called_once_with("db-scan-id")
+ mock_get_session.assert_called_once_with("db-scan-id")
+ mock_cartography_indexes.assert_called_once_with(mock_session, config)
+ mock_prowler_indexes.assert_called_once_with(mock_session)
+ mock_cartography_analysis.assert_called_once_with(mock_session, config)
+ mock_cartography_ontology.assert_called_once_with(mock_session, config)
+ mock_prowler_analysis.assert_called_once_with(
+ mock_session,
+ provider,
+ str(scan.id),
+ config,
+ )
+ mock_get_ingestion.assert_called_once_with(provider.provider)
+ mock_event_loop.assert_called_once()
+ mock_update_progress.assert_any_call(attack_paths_scan, 1)
+ mock_update_progress.assert_any_call(attack_paths_scan, 2)
+ mock_update_progress.assert_any_call(attack_paths_scan, 95)
+ mock_finish.assert_called_once_with(
+ attack_paths_scan, StateChoices.COMPLETED, ingestion_result
+ )
+ mock_get_db_name.assert_called_once_with(attack_paths_scan.id)
+
+ def test_run_failure_marks_scan_failed(
+ self, tenants_fixture, providers_fixture, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+ scan = scans_fixture[0]
+ scan.provider = provider
+ scan.save()
+
+ attack_paths_scan = AttackPathsScan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ scan=scan,
+ state=StateChoices.SCHEDULED,
+ )
+
+ mock_session = MagicMock()
+ session_ctx = MagicMock()
+ session_ctx.__enter__.return_value = mock_session
+ session_ctx.__exit__.return_value = False
+ ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom"))
+
+ with (
+ patch(
+ "tasks.jobs.attack_paths.scan.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.initialize_prowler_provider",
+ return_value=MagicMock(_enabled_regions=["us-east-1"]),
+ ),
+ patch("tasks.jobs.attack_paths.scan.graph_database.get_uri"),
+ patch(
+ "tasks.jobs.attack_paths.scan.graph_database.get_database_name",
+ return_value="db-scan-id",
+ ),
+ patch("tasks.jobs.attack_paths.scan.graph_database.create_database"),
+ patch(
+ "tasks.jobs.attack_paths.scan.graph_database.get_session",
+ return_value=session_ctx,
+ ),
+ patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run"),
+ patch("tasks.jobs.attack_paths.scan.cartography_analysis.run"),
+ patch("tasks.jobs.attack_paths.scan.prowler.create_indexes"),
+ patch("tasks.jobs.attack_paths.scan.prowler.analysis"),
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
+ return_value=attack_paths_scan,
+ ),
+ patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"),
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress"
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan"
+ ) as mock_finish,
+ patch(
+ "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
+ return_value=ingestion_fn,
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan._call_within_event_loop",
+ side_effect=lambda fn, *a, **kw: fn(*a, **kw),
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.utils.stringify_exception",
+ return_value="Cartography failed: ingestion boom",
+ ),
+ ):
+ with pytest.raises(RuntimeError, match="ingestion boom"):
+ attack_paths_run(str(tenant.id), str(scan.id), "task-456")
+
+ failure_args = mock_finish.call_args[0]
+ assert failure_args[0] is attack_paths_scan
+ assert failure_args[1] == StateChoices.FAILED
+ assert failure_args[2] == {
+ "global_cartography_error": "Cartography failed: ingestion boom"
+ }
+
+ def test_run_returns_early_for_unsupported_provider(self, tenants_fixture):
+ tenant = tenants_fixture[0]
+ provider = Provider.objects.create(
+ provider=Provider.ProviderChoices.GCP,
+ uid="gcp-account",
+ alias="gcp",
+ tenant_id=tenant.id,
+ )
+ scan = Scan.objects.create(
+ name="GCP Scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.AVAILABLE,
+ tenant_id=tenant.id,
+ )
+
+ with (
+ patch(
+ "tasks.jobs.attack_paths.scan.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.initialize_prowler_provider",
+ return_value=MagicMock(),
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
+ return_value=None,
+ ) as mock_get_ingestion,
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan"
+ ) as mock_retrieve,
+ ):
+ mock_retrieve.return_value = None
+ result = attack_paths_run(str(tenant.id), str(scan.id), "task-789")
+
+ assert result == {
+ "global_error": "Provider gcp is not supported for Attack Paths scans"
+ }
+ mock_get_ingestion.assert_called_once_with(provider.provider)
+ mock_retrieve.assert_called_once_with(str(tenant.id), str(scan.id))
+
+
+@pytest.mark.django_db
+class TestAttackPathsProwlerHelpers:
+ def test_create_indexes_executes_all_statements(self):
+ mock_session = MagicMock()
+ with patch("tasks.jobs.attack_paths.prowler.run_write_query") as mock_run_write:
+ prowler_module.create_indexes(mock_session)
+
+ assert mock_run_write.call_count == len(prowler_module.INDEX_STATEMENTS)
+ mock_run_write.assert_has_calls(
+ [call(mock_session, stmt) for stmt in prowler_module.INDEX_STATEMENTS]
+ )
+
+ def test_load_findings_batches_requests(self, providers_fixture):
+ provider = providers_fixture[0]
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+
+ # Create a generator that yields two batches
+ def findings_generator():
+ yield [{"id": "1", "resource_uid": "r-1"}]
+ yield [{"id": "2", "resource_uid": "r-2"}]
+
+ config = SimpleNamespace(update_tag=12345)
+ mock_session = MagicMock()
+
+ with (
+ patch(
+ "tasks.jobs.attack_paths.prowler.get_root_node_label",
+ return_value="AWSAccount",
+ ),
+ patch(
+ "tasks.jobs.attack_paths.prowler.get_node_uid_field",
+ return_value="arn",
+ ),
+ ):
+ prowler_module.load_findings(
+ mock_session, findings_generator(), provider, config
+ )
+
+ assert mock_session.run.call_count == 2
+ for call_args in mock_session.run.call_args_list:
+ params = call_args.args[1]
+ assert params["provider_uid"] == str(provider.uid)
+ assert params["last_updated"] == config.update_tag
+ assert "findings_data" in params
+
+ def test_cleanup_findings_runs_batches(self, providers_fixture):
+ provider = providers_fixture[0]
+ config = SimpleNamespace(update_tag=1024)
+ mock_session = MagicMock()
+
+ first_batch = MagicMock()
+ first_batch.single.return_value = {"deleted_findings_count": 3}
+ second_batch = MagicMock()
+ second_batch.single.return_value = {"deleted_findings_count": 0}
+ mock_session.run.side_effect = [first_batch, second_batch]
+
+ prowler_module.cleanup_findings(mock_session, provider, config)
+
+ assert mock_session.run.call_count == 2
+ params = mock_session.run.call_args.args[1]
+ assert params["provider_uid"] == str(provider.uid)
+ assert params["last_updated"] == config.update_tag
+
+ def test_get_provider_last_scan_findings_returns_latest_scan_data(
+ self,
+ tenants_fixture,
+ providers_fixture,
+ ):
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+
+ resource = Resource.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ uid="resource-uid",
+ name="Resource",
+ region="us-east-1",
+ service="ec2",
+ type="instance",
+ )
+
+ older_scan = Scan.objects.create(
+ name="Older",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant.id,
+ )
+ old_finding = Finding.objects.create(
+ tenant_id=tenant.id,
+ uid="older-finding",
+ scan=older_scan,
+ delta=Finding.DeltaChoices.NEW,
+ status=StatusChoices.PASS,
+ status_extended="ok",
+ severity=Severity.low,
+ impact=Severity.low,
+ impact_extended="",
+ raw_result={},
+ check_id="check-old",
+ check_metadata={"checktitle": "Old"},
+ first_seen_at=older_scan.inserted_at,
+ )
+ ResourceFindingMapping.objects.create(
+ tenant_id=tenant.id,
+ resource=resource,
+ finding=old_finding,
+ )
+
+ latest_scan = Scan.objects.create(
+ name="Latest",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant.id,
+ )
+ finding = Finding.objects.create(
+ tenant_id=tenant.id,
+ uid="finding-uid",
+ scan=latest_scan,
+ delta=Finding.DeltaChoices.NEW,
+ status=StatusChoices.FAIL,
+ status_extended="failed",
+ severity=Severity.high,
+ impact=Severity.high,
+ impact_extended="",
+ raw_result={},
+ check_id="check-1",
+ check_metadata={"checktitle": "Check title"},
+ first_seen_at=latest_scan.inserted_at,
+ )
+ ResourceFindingMapping.objects.create(
+ tenant_id=tenant.id,
+ resource=resource,
+ finding=finding,
+ )
+
+ latest_scan.refresh_from_db()
+
+ with patch(
+ "tasks.jobs.attack_paths.prowler.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ ), patch(
+ "tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
+ "default",
+ ):
+ # Generator yields batches, collect all findings from all batches
+ findings_batches = prowler_module.get_provider_last_scan_findings(
+ provider,
+ str(latest_scan.id),
+ )
+ findings_data = []
+ for batch in findings_batches:
+ findings_data.extend(batch)
+
+ assert len(findings_data) == 1
+ finding_dict = findings_data[0]
+ assert finding_dict["id"] == str(finding.id)
+ assert finding_dict["resource_uid"] == resource.uid
+ assert finding_dict["check_title"] == "Check title"
+ assert finding_dict["scan_id"] == str(latest_scan.id)
+
+ def test_enrich_and_flatten_batch_single_resource(
+ self,
+ tenants_fixture,
+ providers_fixture,
+ ):
+ """One finding + one resource = one output dict"""
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+
+ resource = Resource.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ uid="resource-uid-1",
+ name="Resource 1",
+ region="us-east-1",
+ service="ec2",
+ type="instance",
+ )
+
+ scan = Scan.objects.create(
+ name="Test Scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant.id,
+ )
+
+ finding = Finding.objects.create(
+ tenant_id=tenant.id,
+ uid="finding-uid",
+ scan=scan,
+ delta=Finding.DeltaChoices.NEW,
+ status=StatusChoices.FAIL,
+ status_extended="failed",
+ severity=Severity.high,
+ impact=Severity.high,
+ impact_extended="",
+ raw_result={},
+ check_id="check-1",
+ check_metadata={"checktitle": "Check title"},
+ first_seen_at=scan.inserted_at,
+ )
+ ResourceFindingMapping.objects.create(
+ tenant_id=tenant.id,
+ resource=resource,
+ finding=finding,
+ )
+
+ # Simulate the dict returned by .values()
+ finding_dict = {
+ "id": finding.id,
+ "uid": finding.uid,
+ "inserted_at": finding.inserted_at,
+ "updated_at": finding.updated_at,
+ "first_seen_at": finding.first_seen_at,
+ "scan_id": scan.id,
+ "delta": finding.delta,
+ "status": finding.status,
+ "status_extended": finding.status_extended,
+ "severity": finding.severity,
+ "check_id": finding.check_id,
+ "check_metadata__checktitle": finding.check_metadata["checktitle"],
+ "muted": finding.muted,
+ "muted_reason": finding.muted_reason,
+ }
+
+ # _enrich_and_flatten_batch queries ResourceFindingMapping directly
+ # No RLS mock needed - test DB doesn't enforce RLS policies
+ with patch(
+ "tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
+ "default",
+ ):
+ result = prowler_module._enrich_and_flatten_batch([finding_dict])
+
+ assert len(result) == 1
+ assert result[0]["resource_uid"] == resource.uid
+ assert result[0]["id"] == str(finding.id)
+ assert result[0]["status"] == "FAIL"
+
+ def test_enrich_and_flatten_batch_multiple_resources(
+ self,
+ tenants_fixture,
+ providers_fixture,
+ ):
+ """One finding + three resources = three output dicts"""
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+
+ resources = []
+ for i in range(3):
+ resource = Resource.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ uid=f"resource-uid-{i}",
+ name=f"Resource {i}",
+ region="us-east-1",
+ service="ec2",
+ type="instance",
+ )
+ resources.append(resource)
+
+ scan = Scan.objects.create(
+ name="Test Scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant.id,
+ )
+
+ finding = Finding.objects.create(
+ tenant_id=tenant.id,
+ uid="finding-uid",
+ scan=scan,
+ delta=Finding.DeltaChoices.NEW,
+ status=StatusChoices.FAIL,
+ status_extended="failed",
+ severity=Severity.high,
+ impact=Severity.high,
+ impact_extended="",
+ raw_result={},
+ check_id="check-1",
+ check_metadata={"checktitle": "Check title"},
+ first_seen_at=scan.inserted_at,
+ )
+
+ # Map finding to all 3 resources
+ for resource in resources:
+ ResourceFindingMapping.objects.create(
+ tenant_id=tenant.id,
+ resource=resource,
+ finding=finding,
+ )
+
+ finding_dict = {
+ "id": finding.id,
+ "uid": finding.uid,
+ "inserted_at": finding.inserted_at,
+ "updated_at": finding.updated_at,
+ "first_seen_at": finding.first_seen_at,
+ "scan_id": scan.id,
+ "delta": finding.delta,
+ "status": finding.status,
+ "status_extended": finding.status_extended,
+ "severity": finding.severity,
+ "check_id": finding.check_id,
+ "check_metadata__checktitle": finding.check_metadata["checktitle"],
+ "muted": finding.muted,
+ "muted_reason": finding.muted_reason,
+ }
+
+ # _enrich_and_flatten_batch queries ResourceFindingMapping directly
+ # No RLS mock needed - test DB doesn't enforce RLS policies
+ with patch(
+ "tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
+ "default",
+ ):
+ result = prowler_module._enrich_and_flatten_batch([finding_dict])
+
+ assert len(result) == 3
+ result_resource_uids = {r["resource_uid"] for r in result}
+ assert result_resource_uids == {r.uid for r in resources}
+
+ # All should have same finding data
+ for r in result:
+ assert r["id"] == str(finding.id)
+ assert r["status"] == "FAIL"
+
+ def test_enrich_and_flatten_batch_no_resources_skips(
+ self,
+ tenants_fixture,
+ providers_fixture,
+ ):
+ """Finding without resources should be skipped"""
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+
+ scan = Scan.objects.create(
+ name="Test Scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant.id,
+ )
+
+ finding = Finding.objects.create(
+ tenant_id=tenant.id,
+ uid="orphan-finding",
+ scan=scan,
+ delta=Finding.DeltaChoices.NEW,
+ status=StatusChoices.FAIL,
+ status_extended="failed",
+ severity=Severity.high,
+ impact=Severity.high,
+ impact_extended="",
+ raw_result={},
+ check_id="check-1",
+ check_metadata={"checktitle": "Check title"},
+ first_seen_at=scan.inserted_at,
+ )
+ # Note: No ResourceFindingMapping created
+
+ finding_dict = {
+ "id": finding.id,
+ "uid": finding.uid,
+ "inserted_at": finding.inserted_at,
+ "updated_at": finding.updated_at,
+ "first_seen_at": finding.first_seen_at,
+ "scan_id": scan.id,
+ "delta": finding.delta,
+ "status": finding.status,
+ "status_extended": finding.status_extended,
+ "severity": finding.severity,
+ "check_id": finding.check_id,
+ "check_metadata__checktitle": finding.check_metadata["checktitle"],
+ "muted": finding.muted,
+ "muted_reason": finding.muted_reason,
+ }
+
+ # Mock logger to verify no warning is emitted
+ with (
+ patch(
+ "tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS",
+ "default",
+ ),
+ patch("tasks.jobs.attack_paths.prowler.logger") as mock_logger,
+ ):
+ result = prowler_module._enrich_and_flatten_batch([finding_dict])
+
+ assert len(result) == 0
+ mock_logger.warning.assert_not_called()
+
+ def test_generator_is_lazy(self, providers_fixture):
+ """Generator should not execute queries until iterated"""
+ provider = providers_fixture[0]
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+ scan_id = "some-scan-id"
+
+ with (
+ patch("tasks.jobs.attack_paths.prowler.rls_transaction") as mock_rls,
+ patch("tasks.jobs.attack_paths.prowler.Finding") as mock_finding,
+ ):
+ # Create generator but don't iterate
+ prowler_module.get_provider_last_scan_findings(provider, scan_id)
+
+ # Nothing should be called yet
+ mock_rls.assert_not_called()
+ mock_finding.objects.filter.assert_not_called()
+
+ def test_load_findings_empty_generator(self, providers_fixture):
+ """Empty generator should not call neo4j"""
+ provider = providers_fixture[0]
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+
+ mock_session = MagicMock()
+ config = SimpleNamespace(update_tag=12345)
+
+ def empty_gen():
+ return
+ yield # Make it a generator
+
+ with (
+ patch(
+ "tasks.jobs.attack_paths.prowler.get_root_node_label",
+ return_value="AWSAccount",
+ ),
+ patch(
+ "tasks.jobs.attack_paths.prowler.get_node_uid_field",
+ return_value="arn",
+ ),
+ ):
+ prowler_module.load_findings(mock_session, empty_gen(), provider, config)
+
+ mock_session.run.assert_not_called()
diff --git a/api/src/backend/tasks/tests/test_backfill.py b/api/src/backend/tasks/tests/test_backfill.py
index 4c9780d101..04b3158d22 100644
--- a/api/src/backend/tasks/tests/test_backfill.py
+++ b/api/src/backend/tasks/tests/test_backfill.py
@@ -8,6 +8,7 @@ from tasks.jobs.backfill import (
backfill_provider_compliance_scores,
backfill_resource_scan_summaries,
backfill_scan_category_summaries,
+ backfill_scan_resource_group_summaries,
)
from api.models import (
@@ -16,6 +17,7 @@ from api.models import (
ResourceScanSummary,
Scan,
ScanCategorySummary,
+ ScanGroupSummary,
StateChoices,
)
from prowler.lib.check.models import Severity
@@ -265,6 +267,94 @@ class TestBackfillScanCategorySummaries:
assert summary.new_failed_findings == 1
+@pytest.fixture(scope="function")
+def findings_with_group_fixture(scans_fixture, resources_fixture):
+ scan = scans_fixture[0]
+ resource = resources_fixture[0]
+
+ finding = Finding.objects.create(
+ tenant_id=scan.tenant_id,
+ uid="finding_with_group",
+ scan=scan,
+ delta="new",
+ status=Status.FAIL,
+ status_extended="test status",
+ impact=Severity.high,
+ impact_extended="test impact",
+ severity=Severity.high,
+ raw_result={"status": Status.FAIL},
+ check_id="test_check",
+ check_metadata={"CheckId": "test_check"},
+ resource_groups="ai_ml",
+ first_seen_at="2024-01-02T00:00:00Z",
+ )
+ finding.add_resources([resource])
+ return finding
+
+
+@pytest.fixture(scope="function")
+def scan_resource_group_summary_fixture(scans_fixture):
+ scan = scans_fixture[0]
+ return ScanGroupSummary.objects.create(
+ tenant_id=scan.tenant_id,
+ scan=scan,
+ resource_group="existing-group",
+ severity=Severity.high,
+ total_findings=1,
+ failed_findings=0,
+ new_failed_findings=0,
+ resources_count=1,
+ )
+
+
+@pytest.mark.django_db
+class TestBackfillScanGroupSummaries:
+ def test_already_backfilled(self, scan_resource_group_summary_fixture):
+ tenant_id = scan_resource_group_summary_fixture.tenant_id
+ scan_id = scan_resource_group_summary_fixture.scan_id
+
+ result = backfill_scan_resource_group_summaries(str(tenant_id), str(scan_id))
+
+ assert result == {"status": "already backfilled"}
+
+ def test_not_completed_scan(self, get_not_completed_scans):
+ for scan in get_not_completed_scans:
+ result = backfill_scan_resource_group_summaries(
+ str(scan.tenant_id), str(scan.id)
+ )
+ assert result == {"status": "scan is not completed"}
+
+ def test_no_resource_groups_to_backfill(self, scans_fixture):
+ scan = scans_fixture[1] # Failed scan with no findings
+ result = backfill_scan_resource_group_summaries(
+ str(scan.tenant_id), str(scan.id)
+ )
+ assert result == {"status": "no resource groups to backfill"}
+
+ def test_successful_backfill(self, findings_with_group_fixture):
+ finding = findings_with_group_fixture
+ tenant_id = str(finding.tenant_id)
+ scan_id = str(finding.scan_id)
+
+ result = backfill_scan_resource_group_summaries(tenant_id, scan_id)
+
+ # 1 resource group Γ 1 severity = 1 row
+ assert result == {"status": "backfilled", "resource_groups_count": 1}
+
+ summaries = ScanGroupSummary.objects.filter(
+ tenant_id=tenant_id, scan_id=scan_id
+ )
+ assert summaries.count() == 1
+
+ summary = summaries.first()
+ assert summary.resource_group == "ai_ml"
+ assert summary.severity == Severity.high
+ assert summary.total_findings == 1
+ assert summary.failed_findings == 1
+ assert summary.new_failed_findings == 1
+ assert summary.resources_count == 1
+
+
@pytest.mark.django_db
class TestBackfillProviderComplianceScores:
def test_no_completed_scans(self, tenants_fixture):
diff --git a/api/src/backend/tasks/tests/test_connection.py b/api/src/backend/tasks/tests/test_connection.py
index 30973f98bf..e5e39d8778 100644
--- a/api/src/backend/tasks/tests/test_connection.py
+++ b/api/src/backend/tasks/tests/test_connection.py
@@ -82,7 +82,7 @@ def test_check_provider_connection_exception(
[
{
"name": "OpenAI",
- "api_key_decoded": "sk-test1234567890T3BlbkFJtest1234567890",
+ "api_key_decoded": "sk-fake-test-key-for-unit-testing-only",
"model": "gpt-4o",
"temperature": 0,
"max_tokens": 4000,
diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py
index 81cdb44daa..fc90bee0e3 100644
--- a/api/src/backend/tasks/tests/test_deletion.py
+++ b/api/src/backend/tasks/tests/test_deletion.py
@@ -1,27 +1,60 @@
+from unittest.mock import call, patch
+
import pytest
+
from django.core.exceptions import ObjectDoesNotExist
-from tasks.jobs.deletion import delete_provider, delete_tenant
from api.models import Provider, Tenant
+from tasks.jobs.deletion import delete_provider, delete_tenant
@pytest.mark.django_db
class TestDeleteProvider:
def test_delete_provider_success(self, providers_fixture):
- instance = providers_fixture[0]
- tenant_id = str(instance.tenant_id)
- result = delete_provider(tenant_id, instance.id)
+ with patch(
+ "tasks.jobs.deletion.get_provider_graph_database_names"
+ ) as mock_get_provider_graph_database_names, patch(
+ "tasks.jobs.deletion.graph_database.drop_database"
+ ) as mock_drop_database:
+ graph_db_names = ["graph-db-1", "graph-db-2"]
+ mock_get_provider_graph_database_names.return_value = graph_db_names
- assert result
- with pytest.raises(ObjectDoesNotExist):
- Provider.objects.get(pk=instance.id)
+ instance = providers_fixture[0]
+ tenant_id = str(instance.tenant_id)
+ result = delete_provider(tenant_id, instance.id)
+
+ assert result
+ with pytest.raises(ObjectDoesNotExist):
+ Provider.objects.get(pk=instance.id)
+
+ mock_get_provider_graph_database_names.assert_called_once_with(
+ tenant_id, instance.id
+ )
+ mock_drop_database.assert_has_calls(
+ [call(graph_db_name) for graph_db_name in graph_db_names]
+ )
def test_delete_provider_does_not_exist(self, tenants_fixture):
- tenant_id = str(tenants_fixture[0].id)
- non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645"
+ with patch(
+ "tasks.jobs.deletion.get_provider_graph_database_names"
+ ) as mock_get_provider_graph_database_names, patch(
+ "tasks.jobs.deletion.graph_database.drop_database"
+ ) as mock_drop_database:
+ graph_db_names = ["graph-db-1"]
+ mock_get_provider_graph_database_names.return_value = graph_db_names
- with pytest.raises(ObjectDoesNotExist):
- delete_provider(tenant_id, non_existent_pk)
+ tenant_id = str(tenants_fixture[0].id)
+ non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645"
+
+ with pytest.raises(ObjectDoesNotExist):
+ delete_provider(tenant_id, non_existent_pk)
+
+ mock_get_provider_graph_database_names.assert_called_once_with(
+ tenant_id, non_existent_pk
+ )
+ mock_drop_database.assert_has_calls(
+ [call(graph_db_name) for graph_db_name in graph_db_names]
+ )
@pytest.mark.django_db
@@ -30,33 +63,68 @@ class TestDeleteTenant:
"""
Test successful deletion of a tenant and its related data.
"""
- tenant = tenants_fixture[0]
- providers = Provider.objects.filter(tenant_id=tenant.id)
+ with patch(
+ "tasks.jobs.deletion.get_provider_graph_database_names"
+ ) as mock_get_provider_graph_database_names, patch(
+ "tasks.jobs.deletion.graph_database.drop_database"
+ ) as mock_drop_database:
+ tenant = tenants_fixture[0]
+ providers = list(Provider.objects.filter(tenant_id=tenant.id))
- # Ensure the tenant and related providers exist before deletion
- assert Tenant.objects.filter(id=tenant.id).exists()
- assert providers.exists()
+ graph_db_names_per_provider = [
+ [f"graph-db-{provider.id}"] for provider in providers
+ ]
+ mock_get_provider_graph_database_names.side_effect = (
+ graph_db_names_per_provider
+ )
- # Call the function and validate the result
- deletion_summary = delete_tenant(tenant.id)
+ # Ensure the tenant and related providers exist before deletion
+ assert Tenant.objects.filter(id=tenant.id).exists()
+ assert providers
- assert deletion_summary is not None
- assert not Tenant.objects.filter(id=tenant.id).exists()
- assert not Provider.objects.filter(tenant_id=tenant.id).exists()
+ # Call the function and validate the result
+ deletion_summary = delete_tenant(tenant.id)
+
+ assert deletion_summary is not None
+ assert not Tenant.objects.filter(id=tenant.id).exists()
+ assert not Provider.objects.filter(tenant_id=tenant.id).exists()
+
+ expected_calls = [
+ call(provider.tenant_id, provider.id) for provider in providers
+ ]
+ mock_get_provider_graph_database_names.assert_has_calls(
+ expected_calls, any_order=True
+ )
+ assert mock_get_provider_graph_database_names.call_count == len(
+ expected_calls
+ )
+ expected_drop_calls = [
+ call(graph_db_name[0]) for graph_db_name in graph_db_names_per_provider
+ ]
+ mock_drop_database.assert_has_calls(expected_drop_calls, any_order=True)
+ assert mock_drop_database.call_count == len(expected_drop_calls)
def test_delete_tenant_with_no_providers(self, tenants_fixture):
"""
Test deletion of a tenant with no related providers.
"""
- tenant = tenants_fixture[1] # Assume this tenant has no providers
- providers = Provider.objects.filter(tenant_id=tenant.id)
+ with patch(
+ "tasks.jobs.deletion.get_provider_graph_database_names"
+ ) as mock_get_provider_graph_database_names, patch(
+ "tasks.jobs.deletion.graph_database.drop_database"
+ ) as mock_drop_database:
+ tenant = tenants_fixture[1] # Assume this tenant has no providers
+ providers = Provider.objects.filter(tenant_id=tenant.id)
- # Ensure the tenant exists but has no related providers
- assert Tenant.objects.filter(id=tenant.id).exists()
- assert not providers.exists()
+ # Ensure the tenant exists but has no related providers
+ assert Tenant.objects.filter(id=tenant.id).exists()
+ assert not providers.exists()
- # Call the function and validate the result
- deletion_summary = delete_tenant(tenant.id)
+ # Call the function and validate the result
+ deletion_summary = delete_tenant(tenant.id)
- assert deletion_summary == {} # No providers, so empty summary
- assert not Tenant.objects.filter(id=tenant.id).exists()
+ assert deletion_summary == {} # No providers, so empty summary
+ assert not Tenant.objects.filter(id=tenant.id).exists()
+
+ mock_get_provider_graph_database_names.assert_not_called()
+ mock_drop_database.assert_not_called()
diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py
index d37b27e320..954c645998 100644
--- a/api/src/backend/tasks/tests/test_integrations.py
+++ b/api/src/backend/tasks/tests/test_integrations.py
@@ -417,9 +417,8 @@ class TestProwlerIntegrationConnectionTest:
raise_on_exception=False,
)
- @patch("api.utils.AwsProvider")
@patch("api.utils.S3")
- def test_s3_integration_connection_failure(self, mock_s3_class, mock_aws_provider):
+ def test_s3_integration_connection_failure(self, mock_s3_class):
"""Test S3 integration connection failure."""
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.AMAZON_S3
@@ -429,9 +428,6 @@ class TestProwlerIntegrationConnectionTest:
}
integration.configuration = {"bucket_name": "test-bucket"}
- mock_session = MagicMock()
- mock_aws_provider.return_value.session.current_session = mock_session
-
mock_connection = Connection(
is_connected=False, error=Exception("Bucket not found")
)
diff --git a/api/src/backend/tasks/tests/test_report.py b/api/src/backend/tasks/tests/test_report.py
deleted file mode 100644
index 16dd19e0ab..0000000000
--- a/api/src/backend/tasks/tests/test_report.py
+++ /dev/null
@@ -1,1807 +0,0 @@
-import io
-import uuid
-from unittest.mock import MagicMock, Mock, patch
-
-import matplotlib
-import pytest
-from reportlab.lib import colors
-from reportlab.platypus import Table, TableStyle
-from tasks.jobs.report import (
- CHART_COLOR_GREEN_1,
- CHART_COLOR_GREEN_2,
- CHART_COLOR_ORANGE,
- CHART_COLOR_RED,
- CHART_COLOR_YELLOW,
- COLOR_BLUE,
- COLOR_ENS_ALTO,
- COLOR_ENS_BAJO,
- COLOR_ENS_MEDIO,
- COLOR_ENS_OPCIONAL,
- COLOR_HIGH_RISK,
- COLOR_LOW_RISK,
- COLOR_MEDIUM_RISK,
- COLOR_NIS2_PRIMARY,
- COLOR_SAFE,
- _create_dimensions_radar_chart,
- _create_ens_dimension_badges,
- _create_ens_nivel_badge,
- _create_ens_tipo_badge,
- _create_findings_table_style,
- _create_header_table_style,
- _create_info_table_style,
- _create_marco_category_chart,
- _create_nis2_requirements_index,
- _create_nis2_section_chart,
- _create_nis2_subsection_table,
- _create_pdf_styles,
- _create_risk_component,
- _create_section_score_chart,
- _create_status_component,
- _get_chart_color_for_percentage,
- _get_color_for_compliance,
- _get_color_for_risk_level,
- _get_color_for_weight,
- _get_ens_nivel_color,
- _load_findings_for_requirement_checks,
- _safe_getattr,
- generate_compliance_reports_job,
- generate_nis2_report,
- generate_threatscore_report,
-)
-from tasks.jobs.threatscore_utils import (
- _aggregate_requirement_statistics_from_database,
- _calculate_requirements_data_from_statistics,
-)
-
-from api.models import Finding, StatusChoices
-from prowler.lib.check.models import Severity
-
-matplotlib.use("Agg") # Use non-interactive backend for tests
-
-
-@pytest.mark.django_db
-class TestAggregateRequirementStatistics:
- """Test suite for _aggregate_requirement_statistics_from_database function."""
-
- def test_aggregates_findings_correctly(self, tenants_fixture, scans_fixture):
- """Verify correct pass/total counts per check are aggregated from database."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
-
- # Create findings with different check_ids and statuses
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-1",
- check_id="check_1",
- status=StatusChoices.PASS,
- severity=Severity.high,
- impact=Severity.high,
- check_metadata={},
- raw_result={},
- )
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-2",
- check_id="check_1",
- status=StatusChoices.FAIL,
- severity=Severity.high,
- impact=Severity.high,
- check_metadata={},
- raw_result={},
- )
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-3",
- check_id="check_2",
- status=StatusChoices.PASS,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
-
- result = _aggregate_requirement_statistics_from_database(
- str(tenant.id), str(scan.id)
- )
-
- assert result == {
- "check_1": {"passed": 1, "total": 2},
- "check_2": {"passed": 1, "total": 1},
- }
-
- def test_handles_empty_scan(self, tenants_fixture, scans_fixture):
- """Return empty dict when no findings exist for the scan."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
-
- result = _aggregate_requirement_statistics_from_database(
- str(tenant.id), str(scan.id)
- )
-
- assert result == {}
-
- def test_multiple_findings_same_check(self, tenants_fixture, scans_fixture):
- """Aggregate multiple findings for same check_id correctly."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
-
- # Create 5 findings for same check, 3 passed
- for i in range(3):
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid=f"finding-pass-{i}",
- check_id="check_same",
- status=StatusChoices.PASS,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
-
- for i in range(2):
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid=f"finding-fail-{i}",
- check_id="check_same",
- status=StatusChoices.FAIL,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
-
- result = _aggregate_requirement_statistics_from_database(
- str(tenant.id), str(scan.id)
- )
-
- assert result == {"check_same": {"passed": 3, "total": 5}}
-
- def test_only_failed_findings(self, tenants_fixture, scans_fixture):
- """Correctly count when all findings are FAIL status."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
-
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-fail-1",
- check_id="check_fail",
- status=StatusChoices.FAIL,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-fail-2",
- check_id="check_fail",
- status=StatusChoices.FAIL,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
-
- result = _aggregate_requirement_statistics_from_database(
- str(tenant.id), str(scan.id)
- )
-
- assert result == {"check_fail": {"passed": 0, "total": 2}}
-
- def test_mixed_statuses(self, tenants_fixture, scans_fixture):
- """Test with PASS, FAIL, and MANUAL statuses mixed."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
-
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-pass",
- check_id="check_mixed",
- status=StatusChoices.PASS,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-fail",
- check_id="check_mixed",
- status=StatusChoices.FAIL,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-manual",
- check_id="check_mixed",
- status=StatusChoices.MANUAL,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
-
- result = _aggregate_requirement_statistics_from_database(
- str(tenant.id), str(scan.id)
- )
-
- # Only PASS status is counted as passed, MANUAL findings are excluded from total
- assert result == {"check_mixed": {"passed": 1, "total": 2}}
-
-
-@pytest.mark.django_db
-class TestLoadFindingsForChecks:
- """Test suite for _load_findings_for_requirement_checks function."""
-
- def test_loads_only_requested_checks(
- self, tenants_fixture, scans_fixture, providers_fixture
- ):
- """Verify only findings for specified check_ids are loaded."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
- providers_fixture[0]
-
- # Create findings with different check_ids
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-1",
- check_id="check_requested",
- status=StatusChoices.PASS,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-2",
- check_id="check_not_requested",
- status=StatusChoices.FAIL,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
-
- mock_provider = MagicMock()
-
- with patch(
- "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding"
- ) as mock_transform:
- mock_finding_output = MagicMock()
- mock_finding_output.check_id = "check_requested"
- mock_transform.return_value = mock_finding_output
-
- result = _load_findings_for_requirement_checks(
- str(tenant.id), str(scan.id), ["check_requested"], mock_provider
- )
-
- # Only one finding should be loaded
- assert "check_requested" in result
- assert "check_not_requested" not in result
- assert len(result["check_requested"]) == 1
- assert mock_transform.call_count == 1
-
- def test_empty_check_ids_returns_empty(
- self, tenants_fixture, scans_fixture, providers_fixture
- ):
- """Return empty dict when check_ids list is empty."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
- mock_provider = MagicMock()
-
- result = _load_findings_for_requirement_checks(
- str(tenant.id), str(scan.id), [], mock_provider
- )
-
- assert result == {}
-
- def test_groups_by_check_id(
- self, tenants_fixture, scans_fixture, providers_fixture
- ):
- """Multiple findings for same check are grouped correctly."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
-
- # Create multiple findings for same check
- for i in range(3):
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid=f"finding-{i}",
- check_id="check_group",
- status=StatusChoices.PASS,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
-
- mock_provider = MagicMock()
-
- with patch(
- "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding"
- ) as mock_transform:
- mock_finding_output = MagicMock()
- mock_finding_output.check_id = "check_group"
- mock_transform.return_value = mock_finding_output
-
- result = _load_findings_for_requirement_checks(
- str(tenant.id), str(scan.id), ["check_group"], mock_provider
- )
-
- assert len(result["check_group"]) == 3
-
- def test_transforms_to_finding_output(
- self, tenants_fixture, scans_fixture, providers_fixture
- ):
- """Findings are transformed using FindingOutput.transform_api_finding."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
-
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid="finding-transform",
- check_id="check_transform",
- status=StatusChoices.PASS,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
-
- mock_provider = MagicMock()
-
- with patch(
- "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding"
- ) as mock_transform:
- mock_finding_output = MagicMock()
- mock_finding_output.check_id = "check_transform"
- mock_transform.return_value = mock_finding_output
-
- result = _load_findings_for_requirement_checks(
- str(tenant.id), str(scan.id), ["check_transform"], mock_provider
- )
-
- # Verify transform was called
- mock_transform.assert_called_once()
- # Verify the transformed output is in the result
- assert result["check_transform"][0] == mock_finding_output
-
- def test_batched_iteration(self, tenants_fixture, scans_fixture, providers_fixture):
- """Works correctly with multiple batches of findings."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
-
- # Create enough findings to ensure batching (assuming batch size > 1)
- for i in range(10):
- Finding.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- uid=f"finding-batch-{i}",
- check_id="check_batch",
- status=StatusChoices.PASS,
- severity=Severity.medium,
- impact=Severity.medium,
- check_metadata={},
- raw_result={},
- )
-
- mock_provider = MagicMock()
-
- with patch(
- "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding"
- ) as mock_transform:
- mock_finding_output = MagicMock()
- mock_finding_output.check_id = "check_batch"
- mock_transform.return_value = mock_finding_output
-
- result = _load_findings_for_requirement_checks(
- str(tenant.id), str(scan.id), ["check_batch"], mock_provider
- )
-
- # All 10 findings should be loaded regardless of batching
- assert len(result["check_batch"]) == 10
- assert mock_transform.call_count == 10
-
-
-@pytest.mark.django_db
-class TestCalculateRequirementsData:
- """Test suite for _calculate_requirements_data_from_statistics function."""
-
- def test_requirement_status_all_pass(self):
- """Status is PASS when all findings for requirement checks pass."""
- mock_compliance = MagicMock()
- mock_compliance.Framework = "TestFramework"
- mock_compliance.Version = "1.0"
-
- mock_requirement = MagicMock()
- mock_requirement.Id = "req_1"
- mock_requirement.Description = "Test requirement"
- mock_requirement.Checks = ["check_1", "check_2"]
- mock_requirement.Attributes = [MagicMock()]
-
- mock_compliance.Requirements = [mock_requirement]
-
- requirement_statistics = {
- "check_1": {"passed": 5, "total": 5},
- "check_2": {"passed": 3, "total": 3},
- }
-
- attributes_by_id, requirements_list = (
- _calculate_requirements_data_from_statistics(
- mock_compliance, requirement_statistics
- )
- )
-
- assert len(requirements_list) == 1
- assert requirements_list[0]["attributes"]["status"] == StatusChoices.PASS
- assert requirements_list[0]["attributes"]["passed_findings"] == 8
- assert requirements_list[0]["attributes"]["total_findings"] == 8
-
- def test_requirement_status_some_fail(self):
- """Status is FAIL when some findings fail."""
- mock_compliance = MagicMock()
- mock_compliance.Framework = "TestFramework"
- mock_compliance.Version = "1.0"
-
- mock_requirement = MagicMock()
- mock_requirement.Id = "req_2"
- mock_requirement.Description = "Test requirement with failures"
- mock_requirement.Checks = ["check_3"]
- mock_requirement.Attributes = [MagicMock()]
-
- mock_compliance.Requirements = [mock_requirement]
-
- requirement_statistics = {
- "check_3": {"passed": 2, "total": 5},
- }
-
- attributes_by_id, requirements_list = (
- _calculate_requirements_data_from_statistics(
- mock_compliance, requirement_statistics
- )
- )
-
- assert len(requirements_list) == 1
- assert requirements_list[0]["attributes"]["status"] == StatusChoices.FAIL
- assert requirements_list[0]["attributes"]["passed_findings"] == 2
- assert requirements_list[0]["attributes"]["total_findings"] == 5
-
- def test_requirement_status_no_findings(self):
- """Status is MANUAL when no findings exist for requirement."""
- mock_compliance = MagicMock()
- mock_compliance.Framework = "TestFramework"
- mock_compliance.Version = "1.0"
-
- mock_requirement = MagicMock()
- mock_requirement.Id = "req_3"
- mock_requirement.Description = "Manual requirement"
- mock_requirement.Checks = ["check_nonexistent"]
- mock_requirement.Attributes = [MagicMock()]
-
- mock_compliance.Requirements = [mock_requirement]
-
- requirement_statistics = {}
-
- attributes_by_id, requirements_list = (
- _calculate_requirements_data_from_statistics(
- mock_compliance, requirement_statistics
- )
- )
-
- assert len(requirements_list) == 1
- assert requirements_list[0]["attributes"]["status"] == StatusChoices.MANUAL
- assert requirements_list[0]["attributes"]["passed_findings"] == 0
- assert requirements_list[0]["attributes"]["total_findings"] == 0
-
- def test_aggregates_multiple_checks(self):
- """Correctly sum stats across multiple checks in requirement."""
- mock_compliance = MagicMock()
- mock_compliance.Framework = "TestFramework"
- mock_compliance.Version = "1.0"
-
- mock_requirement = MagicMock()
- mock_requirement.Id = "req_4"
- mock_requirement.Description = "Multi-check requirement"
- mock_requirement.Checks = ["check_a", "check_b", "check_c"]
- mock_requirement.Attributes = [MagicMock()]
-
- mock_compliance.Requirements = [mock_requirement]
-
- requirement_statistics = {
- "check_a": {"passed": 10, "total": 15},
- "check_b": {"passed": 5, "total": 10},
- "check_c": {"passed": 0, "total": 5},
- }
-
- attributes_by_id, requirements_list = (
- _calculate_requirements_data_from_statistics(
- mock_compliance, requirement_statistics
- )
- )
-
- assert len(requirements_list) == 1
- # 10 + 5 + 0 = 15 passed
- assert requirements_list[0]["attributes"]["passed_findings"] == 15
- # 15 + 10 + 5 = 30 total
- assert requirements_list[0]["attributes"]["total_findings"] == 30
- # Not all passed, so should be FAIL
- assert requirements_list[0]["attributes"]["status"] == StatusChoices.FAIL
-
- def test_returns_correct_structure(self):
- """Verify tuple structure and dict keys are correct."""
- mock_compliance = MagicMock()
- mock_compliance.Framework = "TestFramework"
- mock_compliance.Version = "1.0"
-
- mock_attribute = MagicMock()
- mock_requirement = MagicMock()
- mock_requirement.Id = "req_5"
- mock_requirement.Description = "Structure test"
- mock_requirement.Checks = ["check_struct"]
- mock_requirement.Attributes = [mock_attribute]
-
- mock_compliance.Requirements = [mock_requirement]
-
- requirement_statistics = {"check_struct": {"passed": 1, "total": 1}}
-
- attributes_by_id, requirements_list = (
- _calculate_requirements_data_from_statistics(
- mock_compliance, requirement_statistics
- )
- )
-
- # Verify attributes_by_id structure
- assert "req_5" in attributes_by_id
- assert "attributes" in attributes_by_id["req_5"]
- assert "description" in attributes_by_id["req_5"]
- assert "req_attributes" in attributes_by_id["req_5"]["attributes"]
- assert "checks" in attributes_by_id["req_5"]["attributes"]
-
- # Verify requirements_list structure
- assert len(requirements_list) == 1
- req = requirements_list[0]
- assert "id" in req
- assert "attributes" in req
- assert "framework" in req["attributes"]
- assert "version" in req["attributes"]
- assert "status" in req["attributes"]
- assert "description" in req["attributes"]
- assert "passed_findings" in req["attributes"]
- assert "total_findings" in req["attributes"]
-
-
-@pytest.mark.django_db
-class TestGenerateThreatscoreReportFunction:
- def setup_method(self):
- self.scan_id = str(uuid.uuid4())
- self.provider_id = str(uuid.uuid4())
- self.tenant_id = str(uuid.uuid4())
- self.compliance_id = "prowler_threatscore_aws"
- self.output_path = "/tmp/test_threatscore_report.pdf"
-
- @patch("tasks.jobs.report.initialize_prowler_provider")
- @patch("tasks.jobs.report.Provider.objects.get")
- @patch("tasks.jobs.report.Compliance.get_bulk")
- @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
- @patch("tasks.jobs.report._calculate_requirements_data_from_statistics")
- @patch("tasks.jobs.report._load_findings_for_requirement_checks")
- @patch("tasks.jobs.report.SimpleDocTemplate")
- @patch("tasks.jobs.report.Image")
- @patch("tasks.jobs.report.Spacer")
- @patch("tasks.jobs.report.Paragraph")
- @patch("tasks.jobs.report.PageBreak")
- @patch("tasks.jobs.report.Table")
- @patch("tasks.jobs.report.TableStyle")
- @patch("tasks.jobs.report.plt.subplots")
- @patch("tasks.jobs.report.plt.savefig")
- @patch("tasks.jobs.report.io.BytesIO")
- def test_generate_threatscore_report_success(
- self,
- mock_bytesio,
- mock_savefig,
- mock_subplots,
- mock_table_style,
- mock_table,
- mock_page_break,
- mock_paragraph,
- mock_spacer,
- mock_image,
- mock_doc_template,
- mock_load_findings,
- mock_calculate_requirements,
- mock_aggregate_statistics,
- mock_compliance_get_bulk,
- mock_provider_get,
- mock_initialize_provider,
- ):
- """Test the updated generate_threatscore_report using new memory-efficient architecture."""
- mock_provider = MagicMock()
- mock_provider.provider = "aws"
- mock_provider_get.return_value = mock_provider
-
- prowler_provider = MagicMock()
- mock_initialize_provider.return_value = prowler_provider
-
- # Mock compliance object with requirements
- mock_compliance_obj = MagicMock()
- mock_compliance_obj.Framework = "ProwlerThreatScore"
- mock_compliance_obj.Version = "1.0"
- mock_compliance_obj.Description = "Test Description"
-
- # Configure requirement with properly set numeric attributes for chart generation
- mock_requirement = MagicMock()
- mock_requirement.Id = "req_1"
- mock_requirement.Description = "Test requirement"
- mock_requirement.Checks = ["check_1"]
-
- # Create a properly configured attribute mock with numeric values
- mock_requirement_attr = MagicMock()
- mock_requirement_attr.Section = "1. IAM"
- mock_requirement_attr.SubSection = "1.1 Identity"
- mock_requirement_attr.Title = "Test Requirement Title"
- mock_requirement_attr.LevelOfRisk = 3
- mock_requirement_attr.Weight = 100
- mock_requirement_attr.AttributeDescription = "Test requirement description"
- mock_requirement_attr.AdditionalInformation = "Additional test information"
-
- mock_requirement.Attributes = [mock_requirement_attr]
- mock_compliance_obj.Requirements = [mock_requirement]
-
- mock_compliance_get_bulk.return_value = {
- self.compliance_id: mock_compliance_obj
- }
-
- # Mock the aggregated statistics from database
- mock_aggregate_statistics.return_value = {"check_1": {"passed": 5, "total": 10}}
-
- # Mock the calculated requirements data with properly configured attributes
- mock_attributes_by_id = {
- "req_1": {
- "attributes": {
- "req_attributes": [mock_requirement_attr],
- "checks": ["check_1"],
- },
- "description": "Test requirement",
- }
- }
- mock_requirements_list = [
- {
- "id": "req_1",
- "attributes": {
- "framework": "ProwlerThreatScore",
- "version": "1.0",
- "status": StatusChoices.FAIL,
- "description": "Test requirement",
- "passed_findings": 5,
- "total_findings": 10,
- },
- }
- ]
- mock_calculate_requirements.return_value = (
- mock_attributes_by_id,
- mock_requirements_list,
- )
-
- # Mock the on-demand loaded findings
- mock_finding_output = MagicMock()
- mock_finding_output.check_id = "check_1"
- mock_finding_output.status = "FAIL"
- mock_finding_output.metadata = MagicMock()
- mock_finding_output.metadata.CheckTitle = "Test Check"
- mock_finding_output.metadata.Severity = "HIGH"
- mock_finding_output.resource_name = "test-resource"
- mock_finding_output.region = "us-east-1"
-
- mock_load_findings.return_value = {"check_1": [mock_finding_output]}
-
- # Mock PDF generation components
- mock_doc = MagicMock()
- mock_doc_template.return_value = mock_doc
-
- mock_fig, mock_ax = MagicMock(), MagicMock()
- mock_subplots.return_value = (mock_fig, mock_ax)
- mock_buffer = MagicMock()
- mock_bytesio.return_value = mock_buffer
-
- mock_image.return_value = MagicMock()
- mock_spacer.return_value = MagicMock()
- mock_paragraph.return_value = MagicMock()
- mock_page_break.return_value = MagicMock()
- mock_table.return_value = MagicMock()
- mock_table_style.return_value = MagicMock()
-
- # Execute the function
- generate_threatscore_report(
- tenant_id=self.tenant_id,
- scan_id=self.scan_id,
- compliance_id=self.compliance_id,
- output_path=self.output_path,
- provider_id=self.provider_id,
- only_failed=True,
- min_risk_level=4,
- )
-
- # Verify the new workflow was followed
- mock_provider_get.assert_called_once_with(id=self.provider_id)
- mock_initialize_provider.assert_called_once_with(mock_provider)
- mock_compliance_get_bulk.assert_called_once_with("aws")
-
- # Verify the new functions were called in correct order with correct parameters
- mock_aggregate_statistics.assert_called_once_with(self.tenant_id, self.scan_id)
- mock_calculate_requirements.assert_called_once_with(
- mock_compliance_obj, {"check_1": {"passed": 5, "total": 10}}
- )
- mock_load_findings.assert_called_once_with(
- self.tenant_id, self.scan_id, ["check_1"], prowler_provider, None
- )
-
- # Verify PDF was built
- mock_doc_template.assert_called_once()
- mock_doc.build.assert_called_once()
-
- @patch("tasks.jobs.report.initialize_prowler_provider")
- @patch("tasks.jobs.report.Provider.objects.get")
- @patch("tasks.jobs.report.Compliance.get_bulk")
- @patch("tasks.jobs.threatscore_utils.Finding.all_objects.filter")
- def test_generate_threatscore_report_exception_handling(
- self,
- mock_finding_filter,
- mock_compliance_get_bulk,
- mock_provider_get,
- mock_initialize_provider,
- ):
- mock_provider_get.side_effect = Exception("Provider not found")
-
- with pytest.raises(Exception, match="Provider not found"):
- generate_threatscore_report(
- tenant_id=self.tenant_id,
- scan_id=self.scan_id,
- compliance_id=self.compliance_id,
- output_path=self.output_path,
- provider_id=self.provider_id,
- only_failed=True,
- min_risk_level=4,
- )
-
-
-@pytest.mark.django_db
-class TestColorHelperFunctions:
- """Test suite for color selection helper functions."""
-
- def test_get_color_for_risk_level_high(self):
- """High risk level (>=4) returns red color."""
- assert _get_color_for_risk_level(4) == COLOR_HIGH_RISK
- assert _get_color_for_risk_level(5) == COLOR_HIGH_RISK
-
- def test_get_color_for_risk_level_medium_high(self):
- """Medium-high risk level (3) returns orange color."""
- assert _get_color_for_risk_level(3) == COLOR_MEDIUM_RISK
-
- def test_get_color_for_risk_level_medium(self):
- """Medium risk level (2) returns yellow color."""
- assert _get_color_for_risk_level(2) == COLOR_LOW_RISK
-
- def test_get_color_for_risk_level_low(self):
- """Low risk level (<2) returns green color."""
- assert _get_color_for_risk_level(0) == COLOR_SAFE
- assert _get_color_for_risk_level(1) == COLOR_SAFE
-
- def test_get_color_for_weight_high(self):
- """High weight (>100) returns red color."""
- assert _get_color_for_weight(101) == COLOR_HIGH_RISK
- assert _get_color_for_weight(200) == COLOR_HIGH_RISK
-
- def test_get_color_for_weight_medium(self):
- """Medium weight (51-100) returns yellow color."""
- assert _get_color_for_weight(51) == COLOR_LOW_RISK
- assert _get_color_for_weight(100) == COLOR_LOW_RISK
-
- def test_get_color_for_weight_low(self):
- """Low weight (<=50) returns green color."""
- assert _get_color_for_weight(0) == COLOR_SAFE
- assert _get_color_for_weight(50) == COLOR_SAFE
-
- def test_get_color_for_compliance_high(self):
- """High compliance (>=80%) returns green color."""
- assert _get_color_for_compliance(80.0) == COLOR_SAFE
- assert _get_color_for_compliance(100.0) == COLOR_SAFE
-
- def test_get_color_for_compliance_medium(self):
- """Medium compliance (60-79%) returns yellow color."""
- assert _get_color_for_compliance(60.0) == COLOR_LOW_RISK
- assert _get_color_for_compliance(79.9) == COLOR_LOW_RISK
-
- def test_get_color_for_compliance_low(self):
- """Low compliance (<60%) returns red color."""
- assert _get_color_for_compliance(0.0) == COLOR_HIGH_RISK
- assert _get_color_for_compliance(59.9) == COLOR_HIGH_RISK
-
- def test_get_chart_color_for_percentage_excellent(self):
- """Excellent percentage (>=80%) returns green."""
- assert _get_chart_color_for_percentage(80.0) == CHART_COLOR_GREEN_1
- assert _get_chart_color_for_percentage(100.0) == CHART_COLOR_GREEN_1
-
- def test_get_chart_color_for_percentage_good(self):
- """Good percentage (60-79%) returns light green."""
- assert _get_chart_color_for_percentage(60.0) == CHART_COLOR_GREEN_2
- assert _get_chart_color_for_percentage(79.9) == CHART_COLOR_GREEN_2
-
- def test_get_chart_color_for_percentage_fair(self):
- """Fair percentage (40-59%) returns yellow."""
- assert _get_chart_color_for_percentage(40.0) == CHART_COLOR_YELLOW
- assert _get_chart_color_for_percentage(59.9) == CHART_COLOR_YELLOW
-
- def test_get_chart_color_for_percentage_poor(self):
- """Poor percentage (20-39%) returns orange."""
- assert _get_chart_color_for_percentage(20.0) == CHART_COLOR_ORANGE
- assert _get_chart_color_for_percentage(39.9) == CHART_COLOR_ORANGE
-
- def test_get_chart_color_for_percentage_critical(self):
- """Critical percentage (<20%) returns red."""
- assert _get_chart_color_for_percentage(0.0) == CHART_COLOR_RED
- assert _get_chart_color_for_percentage(19.9) == CHART_COLOR_RED
-
- def test_get_ens_nivel_color_alto(self):
- """Alto nivel returns red color."""
- assert _get_ens_nivel_color("alto") == COLOR_ENS_ALTO
- assert _get_ens_nivel_color("ALTO") == COLOR_ENS_ALTO
-
- def test_get_ens_nivel_color_medio(self):
- """Medio nivel returns yellow/orange color."""
- assert _get_ens_nivel_color("medio") == COLOR_ENS_MEDIO
- assert _get_ens_nivel_color("MEDIO") == COLOR_ENS_MEDIO
-
- def test_get_ens_nivel_color_bajo(self):
- """Bajo nivel returns green color."""
- assert _get_ens_nivel_color("bajo") == COLOR_ENS_BAJO
- assert _get_ens_nivel_color("BAJO") == COLOR_ENS_BAJO
-
- def test_get_ens_nivel_color_opcional(self):
- """Opcional and unknown nivels return gray color."""
- assert _get_ens_nivel_color("opcional") == COLOR_ENS_OPCIONAL
- assert _get_ens_nivel_color("unknown") == COLOR_ENS_OPCIONAL
-
-
-class TestSafeGetattr:
- """Test suite for _safe_getattr helper function."""
-
- def test_safe_getattr_attribute_exists(self):
- """Returns attribute value when it exists."""
- obj = Mock()
- obj.test_attr = "value"
- assert _safe_getattr(obj, "test_attr") == "value"
-
- def test_safe_getattr_attribute_missing_default(self):
- """Returns default 'N/A' when attribute doesn't exist."""
- obj = Mock(spec=[])
- result = _safe_getattr(obj, "missing_attr")
- assert result == "N/A"
-
- def test_safe_getattr_custom_default(self):
- """Returns custom default when specified."""
- obj = Mock(spec=[])
- result = _safe_getattr(obj, "missing_attr", "custom")
- assert result == "custom"
-
- def test_safe_getattr_none_value(self):
- """Returns None if attribute value is None."""
- obj = Mock()
- obj.test_attr = None
- assert _safe_getattr(obj, "test_attr") is None
-
-
-class TestPDFStylesCreation:
- """Test suite for PDF styles creation and caching."""
-
- def test_create_pdf_styles_returns_dict(self):
- """Returns a dictionary with all required styles."""
- styles = _create_pdf_styles()
-
- assert isinstance(styles, dict)
- assert "title" in styles
- assert "h1" in styles
- assert "h2" in styles
- assert "h3" in styles
- assert "normal" in styles
- assert "normal_center" in styles
-
- def test_create_pdf_styles_caches_result(self):
- """Subsequent calls return cached styles."""
- styles1 = _create_pdf_styles()
- styles2 = _create_pdf_styles()
-
- # Should return the exact same object (not just equal)
- assert styles1 is styles2
-
- def test_pdf_styles_have_correct_fonts(self):
- """Styles use the correct fonts."""
- styles = _create_pdf_styles()
-
- assert styles["title"].fontName == "PlusJakartaSans"
- assert styles["h1"].fontName == "PlusJakartaSans"
- assert styles["normal"].fontName == "PlusJakartaSans"
-
-
-class TestTableStyleFactories:
- """Test suite for table style factory functions."""
-
- def test_create_info_table_style_returns_table_style(self):
- """Returns a TableStyle object."""
- style = _create_info_table_style()
- assert isinstance(style, TableStyle)
-
- def test_create_header_table_style_default_color(self):
- """Uses default blue color when not specified."""
- style = _create_header_table_style()
- assert isinstance(style, TableStyle)
- # Verify it has styling commands
- assert len(style.getCommands()) > 0
-
- def test_create_header_table_style_custom_color(self):
- """Uses custom color when specified."""
- custom_color = colors.red
- style = _create_header_table_style(custom_color)
- assert isinstance(style, TableStyle)
-
- def test_create_findings_table_style(self):
- """Returns appropriate style for findings tables."""
- style = _create_findings_table_style()
- assert isinstance(style, TableStyle)
- assert len(style.getCommands()) > 0
-
-
-class TestRiskComponent:
- """Test suite for _create_risk_component function."""
-
- def test_create_risk_component_returns_table(self):
- """Returns a Table object."""
- table = _create_risk_component(risk_level=3, weight=100, score=50)
- assert isinstance(table, Table)
-
- def test_create_risk_component_high_risk(self):
- """High risk level uses red color."""
- table = _create_risk_component(risk_level=4, weight=50, score=0)
- assert isinstance(table, Table)
- # Table is created successfully
-
- def test_create_risk_component_low_risk(self):
- """Low risk level uses green color."""
- table = _create_risk_component(risk_level=1, weight=30, score=100)
- assert isinstance(table, Table)
-
- def test_create_risk_component_default_score(self):
- """Uses default score of 0 when not specified."""
- table = _create_risk_component(risk_level=2, weight=50)
- assert isinstance(table, Table)
-
-
-class TestStatusComponent:
- """Test suite for _create_status_component function."""
-
- def test_create_status_component_pass(self):
- """PASS status uses green color."""
- table = _create_status_component("pass")
- assert isinstance(table, Table)
-
- def test_create_status_component_fail(self):
- """FAIL status uses red color."""
- table = _create_status_component("fail")
- assert isinstance(table, Table)
-
- def test_create_status_component_manual(self):
- """MANUAL status uses gray color."""
- table = _create_status_component("manual")
- assert isinstance(table, Table)
-
- def test_create_status_component_uppercase(self):
- """Handles uppercase status strings."""
- table = _create_status_component("PASS")
- assert isinstance(table, Table)
-
-
-class TestENSBadges:
- """Test suite for ENS-specific badge creation functions."""
-
- def test_create_ens_nivel_badge_alto(self):
- """Creates badge for alto nivel."""
- table = _create_ens_nivel_badge("alto")
- assert isinstance(table, Table)
-
- def test_create_ens_nivel_badge_medio(self):
- """Creates badge for medio nivel."""
- table = _create_ens_nivel_badge("medio")
- assert isinstance(table, Table)
-
- def test_create_ens_nivel_badge_bajo(self):
- """Creates badge for bajo nivel."""
- table = _create_ens_nivel_badge("bajo")
- assert isinstance(table, Table)
-
- def test_create_ens_nivel_badge_opcional(self):
- """Creates badge for opcional nivel."""
- table = _create_ens_nivel_badge("opcional")
- assert isinstance(table, Table)
-
- def test_create_ens_tipo_badge_requisito(self):
- """Creates badge for requisito type."""
- table = _create_ens_tipo_badge("requisito")
- assert isinstance(table, Table)
-
- def test_create_ens_tipo_badge_unknown(self):
- """Handles unknown tipo gracefully."""
- table = _create_ens_tipo_badge("unknown")
- assert isinstance(table, Table)
-
- def test_create_ens_dimension_badges_single(self):
- """Creates badges for single dimension."""
- table = _create_ens_dimension_badges(["trazabilidad"])
- assert isinstance(table, Table)
-
- def test_create_ens_dimension_badges_multiple(self):
- """Creates badges for multiple dimensions."""
- dimensiones = ["trazabilidad", "autenticidad", "integridad"]
- table = _create_ens_dimension_badges(dimensiones)
- assert isinstance(table, Table)
-
- def test_create_ens_dimension_badges_empty(self):
- """Returns N/A table for empty dimensions list."""
- table = _create_ens_dimension_badges([])
- assert isinstance(table, Table)
-
- def test_create_ens_dimension_badges_invalid(self):
- """Filters out invalid dimensions."""
- table = _create_ens_dimension_badges(["invalid", "trazabilidad"])
- assert isinstance(table, Table)
-
-
-class TestChartCreation:
- """Test suite for chart generation functions."""
-
- @patch("tasks.jobs.report.plt.close")
- @patch("tasks.jobs.report.plt.savefig")
- @patch("tasks.jobs.report.plt.subplots")
- def test_create_section_score_chart_with_data(
- self, mock_subplots, mock_savefig, mock_close
- ):
- """Creates chart successfully with valid data."""
- mock_fig, mock_ax = MagicMock(), MagicMock()
- mock_subplots.return_value = (mock_fig, mock_ax)
- mock_ax.bar.return_value = [MagicMock(), MagicMock()]
-
- requirements_list = [
- {
- "id": "req_1",
- "attributes": {
- "passed_findings": 10,
- "total_findings": 10,
- },
- }
- ]
-
- mock_metadata = MagicMock()
- mock_metadata.Section = "1. IAM"
- mock_metadata.LevelOfRisk = 3
- mock_metadata.Weight = 100
-
- attributes_by_id = {
- "req_1": {
- "attributes": {
- "req_attributes": [mock_metadata],
- }
- }
- }
-
- result = _create_section_score_chart(requirements_list, attributes_by_id)
-
- assert isinstance(result, io.BytesIO)
- mock_subplots.assert_called_once()
- mock_close.assert_called_once_with(mock_fig)
-
- @patch("tasks.jobs.report.plt.close")
- @patch("tasks.jobs.report.plt.savefig")
- @patch("tasks.jobs.report.plt.subplots")
- def test_create_marco_category_chart_with_data(
- self, mock_subplots, mock_savefig, mock_close
- ):
- """Creates marco/category chart successfully."""
- mock_fig, mock_ax = MagicMock(), MagicMock()
- mock_subplots.return_value = (mock_fig, mock_ax)
- mock_ax.barh.return_value = [MagicMock()]
-
- requirements_list = [
- {
- "id": "req_1",
- "attributes": {
- "status": StatusChoices.PASS,
- },
- }
- ]
-
- mock_metadata = MagicMock()
- mock_metadata.Marco = "Marco1"
- mock_metadata.Categoria = "Cat1"
-
- attributes_by_id = {
- "req_1": {
- "attributes": {
- "req_attributes": [mock_metadata],
- }
- }
- }
-
- result = _create_marco_category_chart(requirements_list, attributes_by_id)
-
- assert isinstance(result, io.BytesIO)
- mock_close.assert_called_once_with(mock_fig)
-
- @patch("tasks.jobs.report.plt.close")
- @patch("tasks.jobs.report.plt.savefig")
- @patch("tasks.jobs.report.plt.subplots")
- def test_create_dimensions_radar_chart(
- self, mock_subplots, mock_savefig, mock_close
- ):
- """Creates radar chart for dimensions."""
- mock_fig, mock_ax = MagicMock(), MagicMock()
- mock_ax.plot = MagicMock()
- mock_ax.fill = MagicMock()
- mock_subplots.return_value = (mock_fig, mock_ax)
-
- requirements_list = [
- {
- "id": "req_1",
- "attributes": {
- "status": StatusChoices.PASS,
- },
- }
- ]
-
- mock_metadata = MagicMock()
- mock_metadata.Dimensiones = ["trazabilidad", "integridad"]
-
- attributes_by_id = {
- "req_1": {
- "attributes": {
- "req_attributes": [mock_metadata],
- }
- }
- }
-
- result = _create_dimensions_radar_chart(requirements_list, attributes_by_id)
-
- assert isinstance(result, io.BytesIO)
- mock_close.assert_called_once_with(mock_fig)
-
- @patch("tasks.jobs.report.plt.close")
- @patch("tasks.jobs.report.plt.savefig")
- @patch("tasks.jobs.report.plt.subplots")
- def test_create_chart_closes_figure_on_error(
- self, mock_subplots, mock_savefig, mock_close
- ):
- """Ensures figure is closed even if savefig fails."""
- mock_fig, mock_ax = MagicMock(), MagicMock()
- mock_subplots.return_value = (mock_fig, mock_ax)
- mock_savefig.side_effect = Exception("Save failed")
-
- requirements_list = []
- attributes_by_id = {}
-
- with pytest.raises(Exception):
- _create_section_score_chart(requirements_list, attributes_by_id)
-
- # Verify figure was still closed
- mock_close.assert_called_with(mock_fig)
-
-
-@pytest.mark.django_db
-class TestOptimizationImprovements:
- """Test suite to verify optimization improvements work correctly."""
-
- def test_constants_are_color_objects(self):
- """Verify color constants are properly instantiated Color objects."""
- assert isinstance(COLOR_BLUE, colors.Color)
- assert isinstance(COLOR_HIGH_RISK, colors.Color)
- assert isinstance(COLOR_SAFE, colors.Color)
-
- def test_chart_color_constants_are_strings(self):
- """Verify chart color constants are hex strings."""
- assert isinstance(CHART_COLOR_GREEN_1, str)
- assert CHART_COLOR_GREEN_1.startswith("#")
- assert len(CHART_COLOR_GREEN_1) == 7
-
- def test_style_cache_persists_across_calls(self):
- """Verify style caching reduces object creation."""
- # Clear any existing cache by calling directly
- styles1 = _create_pdf_styles()
- styles2 = _create_pdf_styles()
-
- # Should be the exact same cached object
- assert id(styles1) == id(styles2)
-
- def test_helper_functions_return_consistent_results(self):
- """Verify helper functions return consistent results."""
- # Same input should always return same output
- assert _get_color_for_risk_level(3) == _get_color_for_risk_level(3)
- assert _get_color_for_weight(100) == _get_color_for_weight(100)
- assert _get_chart_color_for_percentage(75.0) == _get_chart_color_for_percentage(
- 75.0
- )
-
-
-@pytest.mark.django_db
-class TestGenerateComplianceReportsOptimized:
- """Test suite for the optimized generate_compliance_reports_job function."""
-
- def setup_method(self):
- self.scan_id = str(uuid.uuid4())
- self.provider_id = str(uuid.uuid4())
- self.tenant_id = str(uuid.uuid4())
-
- def test_no_findings_returns_early_for_both_reports(self):
- """Test that function returns early when no findings exist."""
- with patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter:
- mock_filter.return_value.exists.return_value = False
-
- result = generate_compliance_reports_job(
- tenant_id=self.tenant_id,
- scan_id=self.scan_id,
- provider_id=self.provider_id,
- )
-
- assert result["threatscore"] == {"upload": False, "path": ""}
- assert result["ens"] == {"upload": False, "path": ""}
- mock_filter.assert_called_once_with(scan_id=self.scan_id)
-
- @patch("tasks.jobs.report.rmtree")
- @patch("tasks.jobs.report._upload_to_s3")
- @patch("tasks.jobs.report.generate_nis2_report")
- @patch("tasks.jobs.report.generate_ens_report")
- @patch("tasks.jobs.report.generate_threatscore_report")
- @patch("tasks.jobs.report._generate_compliance_output_directory")
- @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
- @patch("tasks.jobs.report.Provider")
- @patch("tasks.jobs.report.ScanSummary")
- def test_generates_reports_with_shared_queries(
- self,
- mock_scan_summary,
- mock_provider,
- mock_aggregate_stats,
- mock_gen_dir,
- mock_gen_threatscore,
- mock_gen_ens,
- mock_gen_nis2,
- mock_upload,
- mock_rmtree,
- ):
- """Test that requested reports are generated with shared database queries."""
- # Setup mocks
- mock_scan_summary.objects.filter.return_value.exists.return_value = True
- mock_provider_obj = Mock()
- mock_provider_obj.uid = "test-uid"
- mock_provider_obj.provider = "aws"
- mock_provider.objects.get.return_value = mock_provider_obj
-
- mock_aggregate_stats.return_value = {"check-1": {"passed": 10, "total": 15}}
- # Mock returns different paths for different compliance_framework calls
- mock_gen_dir.side_effect = [
- "/tmp/reports/threatscore/output", # First call with compliance_framework="threatscore"
- "/tmp/reports/ens/output", # Second call with compliance_framework="ens"
- "/tmp/reports/nis2/output", # Third call with compliance_framework="nis2"
- ]
- mock_upload.side_effect = [
- "s3://bucket/threatscore.pdf",
- "s3://bucket/ens.pdf",
- "s3://bucket/nis2.pdf",
- ]
-
- result = generate_compliance_reports_job(
- tenant_id=self.tenant_id,
- scan_id=self.scan_id,
- provider_id=self.provider_id,
- generate_threatscore=True,
- generate_ens=True,
- )
-
- # Verify Provider fetched only ONCE (optimization)
- mock_provider.objects.get.assert_called_once_with(id=self.provider_id)
-
- # Verify aggregation called only ONCE (optimization)
- mock_aggregate_stats.assert_called_once_with(self.tenant_id, self.scan_id)
-
- # Verify both report generation functions were called with shared data
- assert mock_gen_threatscore.call_count == 1
- assert mock_gen_ens.call_count == 1
- assert mock_gen_nis2.call_count == 1
-
- # Verify provider_obj and requirement_statistics were passed to both
- threatscore_call_kwargs = mock_gen_threatscore.call_args[1]
- assert threatscore_call_kwargs["provider_obj"] == mock_provider_obj
- assert threatscore_call_kwargs["requirement_statistics"] == {
- "check-1": {"passed": 10, "total": 15}
- }
-
- ens_call_kwargs = mock_gen_ens.call_args[1]
- assert ens_call_kwargs["provider_obj"] == mock_provider_obj
- assert ens_call_kwargs["requirement_statistics"] == {
- "check-1": {"passed": 10, "total": 15}
- }
-
- nis2_call_kwargs = mock_gen_nis2.call_args[1]
- assert nis2_call_kwargs["provider_obj"] == mock_provider_obj
- assert nis2_call_kwargs["requirement_statistics"] == {
- "check-1": {"passed": 10, "total": 15}
- }
-
- # Verify both reports were uploaded successfully
- assert result["threatscore"]["upload"] is True
- assert result["threatscore"]["path"] == "s3://bucket/threatscore.pdf"
- assert result["ens"]["upload"] is True
- assert result["ens"]["path"] == "s3://bucket/ens.pdf"
- assert result["nis2"]["upload"] is True
- assert result["nis2"]["path"] == "s3://bucket/nis2.pdf"
-
- # Cleanup should remove the temporary parent directory when everything uploads
- mock_rmtree.assert_called_once()
- cleanup_path_arg = mock_rmtree.call_args[0][0]
- assert str(cleanup_path_arg) == "/tmp/reports"
-
- @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
- @patch("tasks.jobs.report.Provider")
- @patch("tasks.jobs.report.ScanSummary")
- def test_skips_ens_for_unsupported_provider(
- self, mock_scan_summary, mock_provider, mock_aggregate_stats
- ):
- """Test that ENS report is skipped for M365 provider."""
- mock_scan_summary.objects.filter.return_value.exists.return_value = True
- mock_provider_obj = Mock()
- mock_provider_obj.uid = "test-uid"
- mock_provider_obj.provider = "m365" # Not supported for ENS
- mock_provider.objects.get.return_value = mock_provider_obj
-
- result = generate_compliance_reports_job(
- tenant_id=self.tenant_id,
- scan_id=self.scan_id,
- provider_id=self.provider_id,
- )
-
- # ENS should be skipped, only ThreatScore key should have error/status
- assert "ens" in result
- assert result["ens"]["upload"] is False
-
- def test_findings_cache_reuses_loaded_findings(self):
- """Test that findings cache properly reuses findings across calls."""
- # Create mock findings
- mock_finding1 = Mock()
- mock_finding1.check_id = "check-1"
- mock_finding2 = Mock()
- mock_finding2.check_id = "check-2"
- mock_finding3 = Mock()
- mock_finding3.check_id = "check-1"
-
- mock_output1 = Mock()
- mock_output1.check_id = "check-1"
- mock_output2 = Mock()
- mock_output2.check_id = "check-2"
- mock_output3 = Mock()
- mock_output3.check_id = "check-1"
-
- # Pre-populate cache
- findings_cache = {
- "check-1": [mock_output1, mock_output3],
- }
-
- with (
- patch("tasks.jobs.threatscore_utils.Finding") as mock_finding_class,
- patch("tasks.jobs.threatscore_utils.FindingOutput") as mock_finding_output,
- patch("tasks.jobs.threatscore_utils.rls_transaction"),
- patch("tasks.jobs.threatscore_utils.batched") as mock_batched,
- ):
- # Setup mocks
- mock_finding_class.all_objects.filter.return_value.order_by.return_value.iterator.return_value = [
- mock_finding2
- ]
- mock_batched.return_value = [([mock_finding2], True)]
- mock_finding_output.transform_api_finding.return_value = mock_output2
-
- mock_provider = Mock()
-
- # Call with cache containing check-1, requesting check-1 and check-2
- result = _load_findings_for_requirement_checks(
- tenant_id=self.tenant_id,
- scan_id=self.scan_id,
- check_ids=["check-1", "check-2"],
- prowler_provider=mock_provider,
- findings_cache=findings_cache,
- )
-
- # Verify check-1 was reused from cache (no DB query)
- assert len(result["check-1"]) == 2
- assert result["check-1"] == [mock_output1, mock_output3]
-
- # Verify check-2 was loaded from DB
- assert len(result["check-2"]) == 1
- assert result["check-2"][0] == mock_output2
-
- # Verify cache was updated with check-2
- assert "check-2" in findings_cache
- assert findings_cache["check-2"] == [mock_output2]
-
- # Verify DB was only queried for check-2 (not check-1)
- filter_call = mock_finding_class.all_objects.filter.call_args
- assert filter_call[1]["check_id__in"] == ["check-2"]
-
-
-class TestNIS2SectionChart:
- """Test suite for _create_nis2_section_chart function."""
-
- @pytest.fixture(autouse=True)
- def setup_matplotlib(self):
- """Setup matplotlib backend for tests."""
- matplotlib.use("Agg")
-
- def test_creates_chart_with_sections(self):
- """Verify chart is created with correct sections and compliance data."""
- # Mock requirement with NIS2 section attribute
- mock_attr = Mock()
- mock_attr.Section = (
- "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS"
- )
-
- requirements_list = [
- {
- "id": "1.1.1.a",
- "description": "Test requirement",
- "attributes": {
- "passed_findings": 5,
- "total_findings": 10,
- "status": StatusChoices.FAIL,
- },
- }
- ]
-
- attributes_by_requirement_id = {
- "1.1.1.a": {
- "attributes": {
- "req_attributes": [mock_attr],
- }
- }
- }
-
- # Call function
- result = _create_nis2_section_chart(
- requirements_list, attributes_by_requirement_id
- )
-
- # Verify result is a BytesIO buffer
- assert isinstance(result, io.BytesIO)
- assert result.tell() > 0 # Buffer has content
-
- def test_handles_empty_requirements(self):
- """Verify chart handles empty requirements gracefully."""
- result = _create_nis2_section_chart([], {})
-
- # Verify result is still a valid BytesIO buffer
- assert isinstance(result, io.BytesIO)
-
- def test_calculates_compliance_percentage_correctly(self):
- """Verify compliance percentage calculation is correct."""
- mock_attr1 = Mock()
- mock_attr1.Section = "11 ACCESS CONTROL"
-
- mock_attr2 = Mock()
- mock_attr2.Section = "11 ACCESS CONTROL"
-
- requirements_list = [
- {
- "id": "11.1.1",
- "description": "Test 1",
- "attributes": {
- "passed_findings": 8,
- "total_findings": 10, # 80%
- "status": StatusChoices.PASS,
- },
- },
- {
- "id": "11.1.2",
- "description": "Test 2",
- "attributes": {
- "passed_findings": 10,
- "total_findings": 10, # 100%
- "status": StatusChoices.PASS,
- },
- },
- ]
-
- attributes_by_requirement_id = {
- "11.1.1": {"attributes": {"req_attributes": [mock_attr1]}},
- "11.1.2": {"attributes": {"req_attributes": [mock_attr2]}},
- }
-
- # Call function
- result = _create_nis2_section_chart(
- requirements_list, attributes_by_requirement_id
- )
-
- # Expected: (8+10)/(10+10) = 18/20 = 90%
- assert isinstance(result, io.BytesIO)
-
-
-class TestNIS2SubsectionTable:
- """Test suite for _create_nis2_subsection_table function."""
-
- def test_creates_table_with_subsections(self):
- """Verify table is created with correct subsection breakdown."""
- mock_attr1 = Mock()
- mock_attr1.SubSection = (
- "1.1 Policy on the security of network and information systems"
- )
-
- mock_attr2 = Mock()
- mock_attr2.SubSection = "1.2 Roles, responsibilities and authorities"
-
- requirements_list = [
- {
- "id": "1.1.1.a",
- "description": "Test 1",
- "attributes": {"status": StatusChoices.PASS},
- },
- {
- "id": "1.1.1.b",
- "description": "Test 2",
- "attributes": {"status": StatusChoices.FAIL},
- },
- {
- "id": "1.2.1",
- "description": "Test 3",
- "attributes": {"status": StatusChoices.MANUAL},
- },
- ]
-
- attributes_by_requirement_id = {
- "1.1.1.a": {"attributes": {"req_attributes": [mock_attr1]}},
- "1.1.1.b": {"attributes": {"req_attributes": [mock_attr1]}},
- "1.2.1": {"attributes": {"req_attributes": [mock_attr2]}},
- }
-
- # Call function
- result = _create_nis2_subsection_table(
- requirements_list, attributes_by_requirement_id
- )
-
- # Verify result is a Table
- assert isinstance(result, Table)
-
- # Verify table has correct structure (header + data rows)
- assert len(result._cellvalues) > 1 # At least header + 1 row
-
- # Verify header row
- assert result._cellvalues[0][0] == "SubSection"
- assert result._cellvalues[0][1] == "Total"
- assert result._cellvalues[0][2] == "Pass"
- assert result._cellvalues[0][3] == "Fail"
- assert result._cellvalues[0][4] == "Manual"
- assert result._cellvalues[0][5] == "Compliance %"
-
- def test_table_has_correct_styling(self):
- """Verify table has NIS2 styling applied."""
- mock_attr = Mock()
- mock_attr.SubSection = "Test SubSection"
-
- requirements_list = [
- {
- "id": "1.1.1.a",
- "description": "Test",
- "attributes": {"status": StatusChoices.PASS},
- }
- ]
-
- attributes_by_requirement_id = {
- "1.1.1.a": {"attributes": {"req_attributes": [mock_attr]}}
- }
-
- result = _create_nis2_subsection_table(
- requirements_list, attributes_by_requirement_id
- )
-
- # Verify styling is applied
- assert isinstance(result._cellStyles, list)
- assert len(result._cellStyles) > 0
-
-
-class TestNIS2RequirementsIndex:
- """Test suite for _create_nis2_requirements_index function."""
-
- def test_creates_hierarchical_index(self):
- """Verify index creates hierarchical structure by Section and SubSection."""
- pdf_styles = _create_pdf_styles()
-
- mock_attr1 = Mock()
- mock_attr1.Section = "1 POLICY ON SECURITY"
- mock_attr1.SubSection = "1.1 Policy definition"
-
- mock_attr2 = Mock()
- mock_attr2.Section = "1 POLICY ON SECURITY"
- mock_attr2.SubSection = "1.2 Roles and responsibilities"
-
- requirements_list = [
- {
- "id": "1.1.1.a",
- "description": "Define security policies",
- "attributes": {"status": StatusChoices.PASS},
- },
- {
- "id": "1.2.1",
- "description": "Assign security roles",
- "attributes": {"status": StatusChoices.FAIL},
- },
- ]
-
- attributes_by_requirement_id = {
- "1.1.1.a": {"attributes": {"req_attributes": [mock_attr1]}},
- "1.2.1": {"attributes": {"req_attributes": [mock_attr2]}},
- }
-
- # Call function
- result = _create_nis2_requirements_index(
- requirements_list,
- attributes_by_requirement_id,
- pdf_styles["h2"],
- pdf_styles["h3"],
- pdf_styles["normal"],
- )
-
- # Verify result is a list of elements
- assert isinstance(result, list)
- assert len(result) > 0
-
- def test_includes_status_indicators(self):
- """Verify index includes status indicators (β, β, β)."""
- pdf_styles = _create_pdf_styles()
-
- mock_attr = Mock()
- mock_attr.Section = "Test Section"
- mock_attr.SubSection = "Test SubSection"
-
- requirements_list = [
- {
- "id": "test.1",
- "description": "Passed requirement",
- "attributes": {"status": StatusChoices.PASS},
- },
- {
- "id": "test.2",
- "description": "Failed requirement",
- "attributes": {"status": StatusChoices.FAIL},
- },
- {
- "id": "test.3",
- "description": "Manual requirement",
- "attributes": {"status": StatusChoices.MANUAL},
- },
- ]
-
- attributes_by_requirement_id = {
- "test.1": {"attributes": {"req_attributes": [mock_attr]}},
- "test.2": {"attributes": {"req_attributes": [mock_attr]}},
- "test.3": {"attributes": {"req_attributes": [mock_attr]}},
- }
-
- result = _create_nis2_requirements_index(
- requirements_list,
- attributes_by_requirement_id,
- pdf_styles["h2"],
- pdf_styles["h3"],
- pdf_styles["normal"],
- )
-
- # Convert paragraphs to text and check for status indicators
- str(result)
- # Status indicators should be present in the generated content
- assert len(result) > 0
-
-
-@pytest.mark.django_db
-class TestGenerateNIS2Report:
- """Test suite for generate_nis2_report function."""
-
- @patch("tasks.jobs.report.initialize_prowler_provider")
- @patch("tasks.jobs.report.Provider.objects.get")
- @patch("tasks.jobs.report.ScanSummary.objects.filter")
- @patch("tasks.jobs.report.Compliance.get_bulk")
- @patch("tasks.jobs.report.SimpleDocTemplate")
- def test_generates_nis2_report_successfully(
- self,
- mock_doc,
- mock_compliance,
- mock_scan_summary,
- mock_provider_get,
- mock_init_provider,
- tenants_fixture,
- scans_fixture,
- ):
- """Verify NIS2 report generation completes successfully."""
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
-
- # Setup mocks
- mock_provider = Mock()
- mock_provider.provider = "aws"
- mock_provider.uid = "provider-123"
- mock_provider_get.return_value = mock_provider
-
- mock_scan_summary.return_value.exists.return_value = True
-
- # Mock compliance object
- mock_compliance_obj = Mock()
- mock_compliance_obj.Framework = "NIS2"
- mock_compliance_obj.Name = "Network and Information Security Directive"
- mock_compliance_obj.Version = ""
- mock_compliance_obj.Description = "NIS2 Directive"
- mock_compliance_obj.Requirements = []
-
- mock_compliance.return_value = {"nis2_aws": mock_compliance_obj}
-
- mock_init_provider.return_value = MagicMock()
- mock_doc_instance = Mock()
- mock_doc.return_value = mock_doc_instance
-
- expected_output_path = "/tmp/test_nis2.pdf"
-
- # Call function
- with patch("tasks.jobs.report.rls_transaction"):
- with patch(
- "tasks.jobs.report._aggregate_requirement_statistics_from_database"
- ) as mock_aggregate:
- mock_aggregate.return_value = {}
-
- with patch(
- "tasks.jobs.report._calculate_requirements_data_from_statistics"
- ) as mock_calculate:
- mock_calculate.return_value = ({}, [])
-
- # Should not raise exception
- generate_nis2_report(
- tenant_id=str(tenant.id),
- scan_id=str(scan.id),
- compliance_id="nis2_aws",
- output_path=expected_output_path,
- provider_id="provider-123",
- only_failed=True,
- )
-
- # Verify SimpleDocTemplate was initialized with correct output path
- mock_doc.assert_called_once()
- call_args = mock_doc.call_args
- assert call_args[0][0] == expected_output_path, (
- f"Expected SimpleDocTemplate to be called with {expected_output_path}, "
- f"but got {call_args[0][0]}"
- )
-
- # Verify PDF was built
- mock_doc_instance.build.assert_called_once()
-
- # Verify initialize_prowler_provider was called with the provider
- mock_init_provider.assert_called_once_with(mock_provider)
-
- def test_nis2_colors_are_defined(self):
- """Verify NIS2 specific colors are defined."""
- # Check that NIS2 primary color exists
- assert COLOR_NIS2_PRIMARY is not None
- assert isinstance(COLOR_NIS2_PRIMARY, colors.Color)
diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py
new file mode 100644
index 0000000000..530e0af472
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_reports.py
@@ -0,0 +1,410 @@
+import uuid
+from unittest.mock import Mock, patch
+
+import matplotlib
+import pytest
+from reportlab.lib import colors
+from tasks.jobs.report import generate_compliance_reports, generate_threatscore_report
+from tasks.jobs.reports import (
+ CHART_COLOR_GREEN_1,
+ CHART_COLOR_GREEN_2,
+ CHART_COLOR_ORANGE,
+ CHART_COLOR_RED,
+ CHART_COLOR_YELLOW,
+ COLOR_BLUE,
+ COLOR_ENS_ALTO,
+ COLOR_HIGH_RISK,
+ COLOR_LOW_RISK,
+ COLOR_MEDIUM_RISK,
+ COLOR_NIS2_PRIMARY,
+ COLOR_SAFE,
+ create_pdf_styles,
+ get_chart_color_for_percentage,
+ get_color_for_compliance,
+ get_color_for_risk_level,
+ get_color_for_weight,
+)
+from tasks.jobs.threatscore_utils import (
+ _aggregate_requirement_statistics_from_database,
+ _load_findings_for_requirement_checks,
+)
+
+from api.models import Finding, StatusChoices
+from prowler.lib.check.models import Severity
+
+matplotlib.use("Agg") # Use non-interactive backend for tests
+
+
+@pytest.mark.django_db
+class TestAggregateRequirementStatistics:
+ """Test suite for _aggregate_requirement_statistics_from_database function."""
+
+ def test_aggregates_findings_correctly(self, tenants_fixture, scans_fixture):
+ """Verify correct pass/total counts per check are aggregated from database."""
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+
+ Finding.objects.create(
+ tenant_id=tenant.id,
+ scan=scan,
+ uid="finding-1",
+ check_id="check_1",
+ status=StatusChoices.PASS,
+ severity=Severity.high,
+ impact=Severity.high,
+ check_metadata={},
+ raw_result={},
+ )
+ Finding.objects.create(
+ tenant_id=tenant.id,
+ scan=scan,
+ uid="finding-2",
+ check_id="check_1",
+ status=StatusChoices.FAIL,
+ severity=Severity.high,
+ impact=Severity.high,
+ check_metadata={},
+ raw_result={},
+ )
+ Finding.objects.create(
+ tenant_id=tenant.id,
+ scan=scan,
+ uid="finding-3",
+ check_id="check_2",
+ status=StatusChoices.PASS,
+ severity=Severity.medium,
+ impact=Severity.medium,
+ check_metadata={},
+ raw_result={},
+ )
+
+ result = _aggregate_requirement_statistics_from_database(
+ str(tenant.id), str(scan.id)
+ )
+
+ assert "check_1" in result
+ assert result["check_1"]["passed"] == 1
+ assert result["check_1"]["total"] == 2
+
+ assert "check_2" in result
+ assert result["check_2"]["passed"] == 1
+ assert result["check_2"]["total"] == 1
+
+ def test_handles_empty_scan(self, tenants_fixture, scans_fixture):
+ """Verify empty result is returned for scan with no findings."""
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+
+ result = _aggregate_requirement_statistics_from_database(
+ str(tenant.id), str(scan.id)
+ )
+
+ assert result == {}
+
+ def test_only_failed_findings(self, tenants_fixture, scans_fixture):
+ """Verify correct counts when all findings are FAIL."""
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+
+ Finding.objects.create(
+ tenant_id=tenant.id,
+ scan=scan,
+ uid="finding-1",
+ check_id="check_1",
+ status=StatusChoices.FAIL,
+ severity=Severity.high,
+ impact=Severity.high,
+ check_metadata={},
+ raw_result={},
+ )
+ Finding.objects.create(
+ tenant_id=tenant.id,
+ scan=scan,
+ uid="finding-2",
+ check_id="check_1",
+ status=StatusChoices.FAIL,
+ severity=Severity.high,
+ impact=Severity.high,
+ check_metadata={},
+ raw_result={},
+ )
+
+ result = _aggregate_requirement_statistics_from_database(
+ str(tenant.id), str(scan.id)
+ )
+
+ assert result["check_1"]["passed"] == 0
+ assert result["check_1"]["total"] == 2
+
+ def test_multiple_findings_same_check(self, tenants_fixture, scans_fixture):
+ """Verify multiple findings for same check are correctly aggregated."""
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+
+ for i in range(5):
+ Finding.objects.create(
+ tenant_id=tenant.id,
+ scan=scan,
+ uid=f"finding-{i}",
+ check_id="check_1",
+ status=StatusChoices.PASS if i % 2 == 0 else StatusChoices.FAIL,
+ severity=Severity.high,
+ impact=Severity.high,
+ check_metadata={},
+ raw_result={},
+ )
+
+ result = _aggregate_requirement_statistics_from_database(
+ str(tenant.id), str(scan.id)
+ )
+
+ assert result["check_1"]["passed"] == 3
+ assert result["check_1"]["total"] == 5
+
+ def test_mixed_statuses(self, tenants_fixture, scans_fixture):
+ """Verify MANUAL status is counted in total but not passed."""
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+
+ Finding.objects.create(
+ tenant_id=tenant.id,
+ scan=scan,
+ uid="finding-1",
+ check_id="check_1",
+ status=StatusChoices.PASS,
+ severity=Severity.high,
+ impact=Severity.high,
+ check_metadata={},
+ raw_result={},
+ )
+ Finding.objects.create(
+ tenant_id=tenant.id,
+ scan=scan,
+ uid="finding-2",
+ check_id="check_1",
+ status=StatusChoices.MANUAL,
+ severity=Severity.high,
+ impact=Severity.high,
+ check_metadata={},
+ raw_result={},
+ )
+
+ result = _aggregate_requirement_statistics_from_database(
+ str(tenant.id), str(scan.id)
+ )
+
+ # MANUAL findings are excluded from the aggregation query
+ # since it only counts PASS and FAIL statuses
+ assert result["check_1"]["passed"] == 1
+ assert result["check_1"]["total"] == 1
+
+
+class TestColorHelperFunctions:
+ """Test suite for color helper functions."""
+
+ def test_get_color_for_risk_level_high(self):
+ """Test high risk level returns correct color."""
+ result = get_color_for_risk_level(5)
+ assert result == COLOR_HIGH_RISK
+
+ def test_get_color_for_risk_level_medium_high(self):
+ """Test risk level 4 returns high risk color."""
+ result = get_color_for_risk_level(4)
+ assert result == COLOR_HIGH_RISK # >= 4 is high risk
+
+ def test_get_color_for_risk_level_medium(self):
+ """Test risk level 3 returns medium risk color."""
+ result = get_color_for_risk_level(3)
+ assert result == COLOR_MEDIUM_RISK # >= 3 is medium risk
+
+ def test_get_color_for_risk_level_low(self):
+ """Test low risk level returns safe color."""
+ result = get_color_for_risk_level(1)
+ assert result == COLOR_SAFE # < 2 is safe
+
+ def test_get_color_for_weight_high(self):
+ """Test high weight returns correct color."""
+ result = get_color_for_weight(150)
+ assert result == COLOR_HIGH_RISK # > 100 is high risk
+
+ def test_get_color_for_weight_medium(self):
+ """Test medium weight returns low risk color."""
+ result = get_color_for_weight(100)
+ assert result == COLOR_LOW_RISK # 51-100 is low risk
+
+ def test_get_color_for_weight_low(self):
+ """Test low weight returns safe color."""
+ result = get_color_for_weight(50)
+ assert result == COLOR_SAFE # <= 50 is safe
+
+ def test_get_color_for_compliance_high(self):
+ """Test high compliance returns green color."""
+ result = get_color_for_compliance(85)
+ assert result == COLOR_SAFE
+
+ def test_get_color_for_compliance_medium(self):
+ """Test medium compliance returns yellow color."""
+ result = get_color_for_compliance(70)
+ assert result == COLOR_LOW_RISK
+
+ def test_get_color_for_compliance_low(self):
+ """Test low compliance returns red color."""
+ result = get_color_for_compliance(50)
+ assert result == COLOR_HIGH_RISK
+
+ def test_get_chart_color_for_percentage_excellent(self):
+ """Test excellent percentage returns correct chart color."""
+ result = get_chart_color_for_percentage(90)
+ assert result == CHART_COLOR_GREEN_1
+
+ def test_get_chart_color_for_percentage_good(self):
+ """Test good percentage returns correct chart color."""
+ result = get_chart_color_for_percentage(70)
+ assert result == CHART_COLOR_GREEN_2
+
+ def test_get_chart_color_for_percentage_fair(self):
+ """Test fair percentage returns correct chart color."""
+ result = get_chart_color_for_percentage(50)
+ assert result == CHART_COLOR_YELLOW
+
+ def test_get_chart_color_for_percentage_poor(self):
+ """Test poor percentage returns correct chart color."""
+ result = get_chart_color_for_percentage(30)
+ assert result == CHART_COLOR_ORANGE
+
+ def test_get_chart_color_for_percentage_critical(self):
+ """Test critical percentage returns correct chart color."""
+ result = get_chart_color_for_percentage(10)
+ assert result == CHART_COLOR_RED
+
+
+class TestPDFStylesCreation:
+ """Test suite for PDF styles creation."""
+
+ def test_create_pdf_styles_returns_dict(self):
+ """Test that create_pdf_styles returns a dictionary."""
+ result = create_pdf_styles()
+ assert isinstance(result, dict)
+
+ def test_create_pdf_styles_caches_result(self):
+ """Test that create_pdf_styles caches the result."""
+ result1 = create_pdf_styles()
+ result2 = create_pdf_styles()
+ assert result1 is result2
+
+ def test_pdf_styles_have_correct_keys(self):
+ """Test that PDF styles dictionary has expected keys."""
+ result = create_pdf_styles()
+ expected_keys = ["title", "h1", "h2", "h3", "normal", "normal_center"]
+ for key in expected_keys:
+ assert key in result
+
+
+@pytest.mark.django_db
+class TestLoadFindingsForChecks:
+ """Test suite for _load_findings_for_requirement_checks function."""
+
+ def test_empty_check_ids_returns_empty(self, tenants_fixture, providers_fixture):
+ """Test that empty check_ids list returns empty dict."""
+ tenant = tenants_fixture[0]
+
+ mock_prowler_provider = Mock()
+ mock_prowler_provider.identity.account = "test-account"
+
+ result = _load_findings_for_requirement_checks(
+ str(tenant.id), str(uuid.uuid4()), [], mock_prowler_provider
+ )
+
+ assert result == {}
+
+
+@pytest.mark.django_db
+class TestGenerateThreatscoreReportFunction:
+ """Test suite for generate_threatscore_report function."""
+
+ @patch("tasks.jobs.reports.base.initialize_prowler_provider")
+ def test_generate_threatscore_report_exception_handling(
+ self,
+ mock_initialize_provider,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ """Test that exceptions during report generation are properly handled."""
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = providers_fixture[0]
+
+ mock_initialize_provider.side_effect = Exception("Test exception")
+
+ with pytest.raises(Exception) as exc_info:
+ generate_threatscore_report(
+ tenant_id=str(tenant.id),
+ scan_id=str(scan.id),
+ compliance_id="prowler_threatscore_aws",
+ output_path="/tmp/test_report.pdf",
+ provider_id=str(provider.id),
+ )
+
+ assert "Test exception" in str(exc_info.value)
+
+
+@pytest.mark.django_db
+class TestGenerateComplianceReportsOptimized:
+ """Test suite for generate_compliance_reports function."""
+
+ @patch("tasks.jobs.report._upload_to_s3")
+ @patch("tasks.jobs.report.generate_threatscore_report")
+ @patch("tasks.jobs.report.generate_ens_report")
+ @patch("tasks.jobs.report.generate_nis2_report")
+ def test_no_findings_returns_early_for_both_reports(
+ self,
+ mock_nis2,
+ mock_ens,
+ mock_threatscore,
+ mock_upload,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ """Test that function returns early when scan has no findings."""
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = providers_fixture[0]
+
+ result = generate_compliance_reports(
+ tenant_id=str(tenant.id),
+ scan_id=str(scan.id),
+ provider_id=str(provider.id),
+ generate_threatscore=True,
+ generate_ens=True,
+ generate_nis2=True,
+ )
+
+ assert result["threatscore"]["upload"] is False
+ assert result["ens"]["upload"] is False
+ assert result["nis2"]["upload"] is False
+
+ mock_threatscore.assert_not_called()
+ mock_ens.assert_not_called()
+ mock_nis2.assert_not_called()
+
+
+class TestOptimizationImprovements:
+ """Test suite for optimization-related functionality."""
+
+ def test_chart_color_constants_are_strings(self):
+ """Verify chart color constants are valid hex color strings."""
+ assert CHART_COLOR_GREEN_1.startswith("#")
+ assert CHART_COLOR_GREEN_2.startswith("#")
+ assert CHART_COLOR_YELLOW.startswith("#")
+ assert CHART_COLOR_ORANGE.startswith("#")
+ assert CHART_COLOR_RED.startswith("#")
+
+ def test_color_constants_are_color_objects(self):
+ """Verify color constants are Color objects."""
+ assert isinstance(COLOR_BLUE, colors.Color)
+ assert isinstance(COLOR_HIGH_RISK, colors.Color)
+ assert isinstance(COLOR_SAFE, colors.Color)
+ assert isinstance(COLOR_ENS_ALTO, colors.Color)
+ assert isinstance(COLOR_NIS2_PRIMARY, colors.Color)
diff --git a/api/src/backend/tasks/tests/test_reports_base.py b/api/src/backend/tasks/tests/test_reports_base.py
new file mode 100644
index 0000000000..d2fda4f830
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_reports_base.py
@@ -0,0 +1,1346 @@
+import io
+
+import pytest
+from reportlab.lib.units import inch
+from reportlab.platypus import Image, LongTable, Paragraph, Spacer, Table
+from tasks.jobs.reports import ( # Configuration; Colors; Components; Charts; Base
+ CHART_COLOR_GREEN_1,
+ CHART_COLOR_GREEN_2,
+ CHART_COLOR_ORANGE,
+ CHART_COLOR_RED,
+ CHART_COLOR_YELLOW,
+ COLOR_BLUE,
+ COLOR_DARK_GRAY,
+ COLOR_HIGH_RISK,
+ COLOR_LOW_RISK,
+ COLOR_MEDIUM_RISK,
+ COLOR_SAFE,
+ FRAMEWORK_REGISTRY,
+ BaseComplianceReportGenerator,
+ ColumnConfig,
+ ComplianceData,
+ FrameworkConfig,
+ RequirementData,
+ create_badge,
+ create_data_table,
+ create_findings_table,
+ create_horizontal_bar_chart,
+ create_info_table,
+ create_multi_badge_row,
+ create_pdf_styles,
+ create_pie_chart,
+ create_radar_chart,
+ create_risk_component,
+ create_section_header,
+ create_stacked_bar_chart,
+ create_status_badge,
+ create_summary_table,
+ create_vertical_bar_chart,
+ get_chart_color_for_percentage,
+ get_color_for_compliance,
+ get_color_for_risk_level,
+ get_color_for_weight,
+ get_framework_config,
+ get_status_color,
+)
+
+# =============================================================================
+# Configuration Tests
+# =============================================================================
+
+
+class TestFrameworkConfig:
+ """Tests for FrameworkConfig dataclass."""
+
+ def test_framework_config_creation(self):
+ """Test creating a FrameworkConfig with required fields."""
+ config = FrameworkConfig(
+ name="test_framework",
+ display_name="Test Framework",
+ )
+
+ assert config.name == "test_framework"
+ assert config.display_name == "Test Framework"
+ assert config.logo_filename is None
+ assert config.language == "en"
+ assert config.has_risk_levels is False
+
+ def test_framework_config_with_all_fields(self):
+ """Test creating a FrameworkConfig with all fields."""
+ config = FrameworkConfig(
+ name="custom",
+ display_name="Custom Framework",
+ logo_filename="custom_logo.png",
+ primary_color=COLOR_BLUE,
+ secondary_color=COLOR_SAFE,
+ attribute_fields=["Section", "SubSection"],
+ sections=["1. Security", "2. Compliance"],
+ language="es",
+ has_risk_levels=True,
+ has_dimensions=True,
+ has_niveles=True,
+ has_weight=True,
+ )
+
+ assert config.name == "custom"
+ assert config.logo_filename == "custom_logo.png"
+ assert config.language == "es"
+ assert config.has_risk_levels is True
+ assert config.has_dimensions is True
+ assert len(config.attribute_fields) == 2
+ assert len(config.sections) == 2
+
+
+class TestFrameworkRegistry:
+ """Tests for the framework registry."""
+
+ def test_registry_contains_threatscore(self):
+ """Test that ThreatScore is in the registry."""
+ assert "prowler_threatscore" in FRAMEWORK_REGISTRY
+ config = FRAMEWORK_REGISTRY["prowler_threatscore"]
+ assert config.has_risk_levels is True
+ assert config.has_weight is True
+
+ def test_registry_contains_ens(self):
+ """Test that ENS is in the registry."""
+ assert "ens" in FRAMEWORK_REGISTRY
+ config = FRAMEWORK_REGISTRY["ens"]
+ assert config.language == "es"
+ assert config.has_niveles is True
+ assert config.has_dimensions is True
+
+ def test_registry_contains_nis2(self):
+ """Test that NIS2 is in the registry."""
+ assert "nis2" in FRAMEWORK_REGISTRY
+ config = FRAMEWORK_REGISTRY["nis2"]
+ assert config.language == "en"
+
+ def test_get_framework_config_threatscore(self):
+ """Test getting ThreatScore config."""
+ config = get_framework_config("prowler_threatscore_aws")
+ assert config is not None
+ assert config.name == "prowler_threatscore"
+
+ def test_get_framework_config_ens(self):
+ """Test getting ENS config."""
+ config = get_framework_config("ens_rd2022_aws")
+ assert config is not None
+ assert config.name == "ens"
+
+ def test_get_framework_config_nis2(self):
+ """Test getting NIS2 config."""
+ config = get_framework_config("nis2_aws")
+ assert config is not None
+ assert config.name == "nis2"
+
+ def test_get_framework_config_unknown(self):
+ """Test getting unknown framework returns None."""
+ config = get_framework_config("unknown_framework")
+ assert config is None
+
+
+# =============================================================================
+# Color Helper Tests
+# =============================================================================
+
+
+class TestColorHelpers:
+ """Tests for color helper functions."""
+
+ def test_get_color_for_risk_level_high(self):
+ """Test high risk level returns red."""
+ assert get_color_for_risk_level(5) == COLOR_HIGH_RISK
+ assert get_color_for_risk_level(4) == COLOR_HIGH_RISK
+
+ def test_get_color_for_risk_level_very_high(self):
+ """Test very high risk level (>5) still returns high risk color."""
+ assert get_color_for_risk_level(10) == COLOR_HIGH_RISK
+ assert get_color_for_risk_level(100) == COLOR_HIGH_RISK
+
+ def test_get_color_for_risk_level_medium(self):
+ """Test medium risk level returns orange."""
+ assert get_color_for_risk_level(3) == COLOR_MEDIUM_RISK
+
+ def test_get_color_for_risk_level_low(self):
+ """Test low risk level returns yellow."""
+ assert get_color_for_risk_level(2) == COLOR_LOW_RISK
+
+ def test_get_color_for_risk_level_safe(self):
+ """Test safe risk level returns green."""
+ assert get_color_for_risk_level(1) == COLOR_SAFE
+ assert get_color_for_risk_level(0) == COLOR_SAFE
+
+ def test_get_color_for_risk_level_negative(self):
+ """Test negative risk level returns safe color."""
+ assert get_color_for_risk_level(-1) == COLOR_SAFE
+
+ def test_get_color_for_weight_high(self):
+ """Test high weight returns red."""
+ assert get_color_for_weight(150) == COLOR_HIGH_RISK
+ assert get_color_for_weight(101) == COLOR_HIGH_RISK
+
+ def test_get_color_for_weight_medium(self):
+ """Test medium weight returns yellow."""
+ assert get_color_for_weight(100) == COLOR_LOW_RISK
+ assert get_color_for_weight(51) == COLOR_LOW_RISK
+
+ def test_get_color_for_weight_low(self):
+ """Test low weight returns green."""
+ assert get_color_for_weight(50) == COLOR_SAFE
+ assert get_color_for_weight(0) == COLOR_SAFE
+
+ def test_get_color_for_compliance_high(self):
+ """Test high compliance returns green."""
+ assert get_color_for_compliance(100) == COLOR_SAFE
+ assert get_color_for_compliance(80) == COLOR_SAFE
+
+ def test_get_color_for_compliance_medium(self):
+ """Test medium compliance returns yellow."""
+ assert get_color_for_compliance(79) == COLOR_LOW_RISK
+ assert get_color_for_compliance(60) == COLOR_LOW_RISK
+
+ def test_get_color_for_compliance_low(self):
+ """Test low compliance returns red."""
+ assert get_color_for_compliance(59) == COLOR_HIGH_RISK
+ assert get_color_for_compliance(0) == COLOR_HIGH_RISK
+
+ def test_get_status_color_pass(self):
+ """Test PASS status returns green."""
+ assert get_status_color("PASS") == COLOR_SAFE
+ assert get_status_color("pass") == COLOR_SAFE
+
+ def test_get_status_color_fail(self):
+ """Test FAIL status returns red."""
+ assert get_status_color("FAIL") == COLOR_HIGH_RISK
+ assert get_status_color("fail") == COLOR_HIGH_RISK
+
+ def test_get_status_color_manual(self):
+ """Test MANUAL status returns gray."""
+ assert get_status_color("MANUAL") == COLOR_DARK_GRAY
+
+
+class TestChartColorHelpers:
+ """Tests for chart color functions."""
+
+ def test_chart_color_for_high_percentage(self):
+ """Test high percentage returns green."""
+ assert get_chart_color_for_percentage(100) == CHART_COLOR_GREEN_1
+ assert get_chart_color_for_percentage(80) == CHART_COLOR_GREEN_1
+
+ def test_chart_color_for_medium_high_percentage(self):
+ """Test medium-high percentage returns light green."""
+ assert get_chart_color_for_percentage(79) == CHART_COLOR_GREEN_2
+ assert get_chart_color_for_percentage(60) == CHART_COLOR_GREEN_2
+
+ def test_chart_color_for_medium_percentage(self):
+ """Test medium percentage returns yellow."""
+ assert get_chart_color_for_percentage(59) == CHART_COLOR_YELLOW
+ assert get_chart_color_for_percentage(40) == CHART_COLOR_YELLOW
+
+ def test_chart_color_for_medium_low_percentage(self):
+ """Test medium-low percentage returns orange."""
+ assert get_chart_color_for_percentage(39) == CHART_COLOR_ORANGE
+ assert get_chart_color_for_percentage(20) == CHART_COLOR_ORANGE
+
+ def test_chart_color_for_low_percentage(self):
+ """Test low percentage returns red."""
+ assert get_chart_color_for_percentage(19) == CHART_COLOR_RED
+ assert get_chart_color_for_percentage(0) == CHART_COLOR_RED
+
+ def test_chart_color_boundary_values(self):
+ """Test chart color at exact boundary values."""
+ # Exact boundaries
+ assert get_chart_color_for_percentage(80) == CHART_COLOR_GREEN_1
+ assert get_chart_color_for_percentage(60) == CHART_COLOR_GREEN_2
+ assert get_chart_color_for_percentage(40) == CHART_COLOR_YELLOW
+ assert get_chart_color_for_percentage(20) == CHART_COLOR_ORANGE
+
+
+# =============================================================================
+# Component Tests
+# =============================================================================
+
+
+class TestBadgeComponents:
+ """Tests for badge component functions."""
+
+ def test_create_badge_returns_table(self):
+ """Test create_badge returns a Table object."""
+ badge = create_badge("Test", COLOR_BLUE)
+ assert isinstance(badge, Table)
+
+ def test_create_badge_with_custom_width(self):
+ """Test create_badge with custom width."""
+ badge = create_badge("Test", COLOR_BLUE, width=2 * inch)
+ assert badge is not None
+
+ def test_create_status_badge_pass(self):
+ """Test status badge for PASS."""
+ badge = create_status_badge("PASS")
+ assert isinstance(badge, Table)
+
+ def test_create_status_badge_fail(self):
+ """Test status badge for FAIL."""
+ badge = create_status_badge("FAIL")
+ assert badge is not None
+
+ def test_create_multi_badge_row_with_badges(self):
+ """Test multi-badge row with data."""
+ badges = [
+ ("A", COLOR_BLUE),
+ ("B", COLOR_SAFE),
+ ]
+ table = create_multi_badge_row(badges)
+ assert isinstance(table, Table)
+
+ def test_create_multi_badge_row_empty(self):
+ """Test multi-badge row with empty list."""
+ table = create_multi_badge_row([])
+ assert table is not None
+
+
+class TestRiskComponent:
+ """Tests for risk component function."""
+
+ def test_create_risk_component_returns_table(self):
+ """Test risk component returns a Table."""
+ component = create_risk_component(risk_level=4, weight=100, score=50)
+ assert isinstance(component, Table)
+
+ def test_create_risk_component_high_risk(self):
+ """Test risk component with high risk level."""
+ component = create_risk_component(risk_level=5, weight=150, score=100)
+ assert component is not None
+
+ def test_create_risk_component_low_risk(self):
+ """Test risk component with low risk level."""
+ component = create_risk_component(risk_level=1, weight=10, score=10)
+ assert component is not None
+
+
+class TestTableComponents:
+ """Tests for table component functions."""
+
+ def test_create_info_table(self):
+ """Test info table creation."""
+ rows = [
+ ("Label 1:", "Value 1"),
+ ("Label 2:", "Value 2"),
+ ]
+ table = create_info_table(rows)
+ assert isinstance(table, Table)
+
+ def test_create_info_table_with_custom_widths(self):
+ """Test info table with custom column widths."""
+ rows = [("Test:", "Value")]
+ table = create_info_table(rows, label_width=3 * inch, value_width=3 * inch)
+ assert table is not None
+
+ def test_create_data_table(self):
+ """Test data table creation."""
+ data = [
+ {"name": "Item 1", "value": "100"},
+ {"name": "Item 2", "value": "200"},
+ ]
+ columns = [
+ ColumnConfig("Name", 2 * inch, "name"),
+ ColumnConfig("Value", 1 * inch, "value"),
+ ]
+ table = create_data_table(data, columns)
+ assert isinstance(table, Table)
+
+ def test_create_data_table_with_callable_field(self):
+ """Test data table with callable field."""
+ data = [{"raw_value": 100}]
+ columns = [
+ ColumnConfig("Formatted", 2 * inch, lambda x: f"${x['raw_value']}"),
+ ]
+ table = create_data_table(data, columns)
+ assert table is not None
+
+ def test_create_summary_table(self):
+ """Test summary table creation."""
+ table = create_summary_table(
+ label="Score:",
+ value="85%",
+ value_color=COLOR_SAFE,
+ )
+ assert isinstance(table, Table)
+
+ def test_create_summary_table_with_custom_widths(self):
+ """Test summary table with custom widths."""
+ table = create_summary_table(
+ label="ThreatScore:",
+ value="92.5%",
+ value_color=COLOR_SAFE,
+ label_width=3 * inch,
+ value_width=2.5 * inch,
+ )
+ assert isinstance(table, Table)
+
+
+class TestFindingsTable:
+ """Tests for findings table component."""
+
+ def test_create_findings_table_with_dicts(self):
+ """Test findings table creation with dict data."""
+ findings = [
+ {
+ "title": "Finding 1",
+ "resource_name": "resource-1",
+ "severity": "HIGH",
+ "status": "FAIL",
+ "region": "us-east-1",
+ },
+ {
+ "title": "Finding 2",
+ "resource_name": "resource-2",
+ "severity": "LOW",
+ "status": "PASS",
+ "region": "eu-west-1",
+ },
+ ]
+ table = create_findings_table(findings)
+ assert isinstance(table, Table)
+
+ def test_create_findings_table_with_custom_columns(self):
+ """Test findings table with custom column configuration."""
+ findings = [{"name": "Test", "value": "100"}]
+ columns = [
+ ColumnConfig("Name", 2 * inch, "name"),
+ ColumnConfig("Value", 1 * inch, "value"),
+ ]
+ table = create_findings_table(findings, columns=columns)
+ assert table is not None
+
+ def test_create_findings_table_empty(self):
+ """Test findings table with empty list."""
+ table = create_findings_table([])
+ assert table is not None
+
+
+class TestSectionHeader:
+ """Tests for section header component."""
+
+ def test_create_section_header_with_spacer(self):
+ """Test section header with spacer."""
+ styles = create_pdf_styles()
+ elements = create_section_header("Test Header", styles["h1"])
+
+ assert len(elements) == 2
+ assert isinstance(elements[0], Paragraph)
+ assert isinstance(elements[1], Spacer)
+
+ def test_create_section_header_without_spacer(self):
+ """Test section header without spacer."""
+ styles = create_pdf_styles()
+ elements = create_section_header("Test Header", styles["h1"], add_spacer=False)
+
+ assert len(elements) == 1
+ assert isinstance(elements[0], Paragraph)
+
+ def test_create_section_header_custom_spacer_height(self):
+ """Test section header with custom spacer height."""
+ styles = create_pdf_styles()
+ elements = create_section_header("Test Header", styles["h2"], spacer_height=0.5)
+
+ assert len(elements) == 2
+
+
+# =============================================================================
+# Chart Tests
+# =============================================================================
+
+
+class TestChartCreation:
+ """Tests for chart creation functions."""
+
+ def test_create_vertical_bar_chart(self):
+ """Test vertical bar chart creation."""
+ buffer = create_vertical_bar_chart(
+ labels=["A", "B", "C"],
+ values=[80, 60, 40],
+ )
+ assert isinstance(buffer, io.BytesIO)
+ assert buffer.getvalue() # Not empty
+
+ def test_create_vertical_bar_chart_with_options(self):
+ """Test vertical bar chart with custom options."""
+ buffer = create_vertical_bar_chart(
+ labels=["Section 1", "Section 2"],
+ values=[90, 70],
+ ylabel="Compliance",
+ title="Test Chart",
+ figsize=(8, 6),
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_horizontal_bar_chart(self):
+ """Test horizontal bar chart creation."""
+ buffer = create_horizontal_bar_chart(
+ labels=["Category 1", "Category 2", "Category 3"],
+ values=[85, 65, 45],
+ )
+ assert isinstance(buffer, io.BytesIO)
+ assert buffer.getvalue()
+
+ def test_create_horizontal_bar_chart_with_options(self):
+ """Test horizontal bar chart with custom options."""
+ buffer = create_horizontal_bar_chart(
+ labels=["A", "B"],
+ values=[100, 50],
+ xlabel="Percentage",
+ title="Custom Chart",
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_radar_chart(self):
+ """Test radar chart creation."""
+ buffer = create_radar_chart(
+ labels=["Dim 1", "Dim 2", "Dim 3", "Dim 4", "Dim 5"],
+ values=[80, 70, 60, 90, 75],
+ )
+ assert isinstance(buffer, io.BytesIO)
+ assert buffer.getvalue()
+
+ def test_create_radar_chart_with_options(self):
+ """Test radar chart with custom options."""
+ buffer = create_radar_chart(
+ labels=["A", "B", "C"],
+ values=[50, 60, 70],
+ color="#FF0000",
+ fill_alpha=0.5,
+ title="Custom Radar",
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_pie_chart(self):
+ """Test pie chart creation."""
+ buffer = create_pie_chart(
+ labels=["Pass", "Fail"],
+ values=[80, 20],
+ )
+ assert isinstance(buffer, io.BytesIO)
+ assert buffer.getvalue()
+
+ def test_create_pie_chart_with_options(self):
+ """Test pie chart with custom options."""
+ buffer = create_pie_chart(
+ labels=["Pass", "Fail", "Manual"],
+ values=[60, 30, 10],
+ colors=["#4CAF50", "#F44336", "#9E9E9E"],
+ title="Status Distribution",
+ autopct="%1.0f%%",
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_stacked_bar_chart(self):
+ """Test stacked bar chart creation."""
+ buffer = create_stacked_bar_chart(
+ labels=["Section 1", "Section 2", "Section 3"],
+ data_series={
+ "Pass": [8, 6, 4],
+ "Fail": [2, 4, 6],
+ },
+ )
+ assert isinstance(buffer, io.BytesIO)
+ assert buffer.getvalue()
+
+ def test_create_stacked_bar_chart_with_options(self):
+ """Test stacked bar chart with custom options."""
+ buffer = create_stacked_bar_chart(
+ labels=["A", "B"],
+ data_series={
+ "Pass": [10, 5],
+ "Fail": [2, 3],
+ "Manual": [1, 2],
+ },
+ colors={
+ "Pass": "#4CAF50",
+ "Fail": "#F44336",
+ "Manual": "#9E9E9E",
+ },
+ xlabel="Categories",
+ ylabel="Requirements",
+ title="Requirements by Status",
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_stacked_bar_chart_without_legend(self):
+ """Test stacked bar chart without legend."""
+ buffer = create_stacked_bar_chart(
+ labels=["X", "Y"],
+ data_series={"A": [1, 2]},
+ show_legend=False,
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_vertical_bar_chart_without_labels(self):
+ """Test vertical bar chart without value labels."""
+ buffer = create_vertical_bar_chart(
+ labels=["A", "B"],
+ values=[50, 75],
+ show_labels=False,
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_vertical_bar_chart_with_explicit_colors(self):
+ """Test vertical bar chart with explicit color list."""
+ buffer = create_vertical_bar_chart(
+ labels=["Pass", "Fail"],
+ values=[80, 20],
+ colors=["#4CAF50", "#F44336"],
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_horizontal_bar_chart_auto_figsize(self):
+ """Test horizontal bar chart auto-calculates figure size for many items."""
+ labels = [f"Item {i}" for i in range(20)]
+ values = [50 + i * 2 for i in range(20)]
+ buffer = create_horizontal_bar_chart(
+ labels=labels,
+ values=values,
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_horizontal_bar_chart_with_explicit_colors(self):
+ """Test horizontal bar chart with explicit colors."""
+ buffer = create_horizontal_bar_chart(
+ labels=["A", "B", "C"],
+ values=[80, 60, 40],
+ colors=["#4CAF50", "#FFEB3B", "#F44336"],
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_create_radar_chart_with_custom_ticks(self):
+ """Test radar chart with custom y-axis ticks."""
+ buffer = create_radar_chart(
+ labels=["A", "B", "C", "D"],
+ values=[25, 50, 75, 100],
+ y_ticks=[0, 25, 50, 75, 100],
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+
+# =============================================================================
+# Data Class Tests
+# =============================================================================
+
+
+class TestDataClasses:
+ """Tests for data classes."""
+
+ def test_requirement_data_creation(self):
+ """Test RequirementData creation."""
+ req = RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status="PASS",
+ passed_findings=10,
+ total_findings=10,
+ )
+ assert req.id == "REQ-001"
+ assert req.status == "PASS"
+ assert req.passed_findings == 10
+
+ def test_requirement_data_with_failed_findings(self):
+ """Test RequirementData with failed findings."""
+ req = RequirementData(
+ id="REQ-002",
+ description="Failed requirement",
+ status="FAIL",
+ passed_findings=3,
+ failed_findings=7,
+ total_findings=10,
+ )
+ assert req.failed_findings == 7
+ assert req.total_findings == 10
+
+ def test_requirement_data_defaults(self):
+ """Test RequirementData default values."""
+ req = RequirementData(
+ id="REQ-003",
+ description="Minimal requirement",
+ status="MANUAL",
+ )
+ assert req.passed_findings == 0
+ assert req.failed_findings == 0
+ assert req.total_findings == 0
+
+ def test_compliance_data_creation(self):
+ """Test ComplianceData creation."""
+ data = ComplianceData(
+ tenant_id="tenant-123",
+ scan_id="scan-456",
+ provider_id="provider-789",
+ compliance_id="test_compliance",
+ framework="Test",
+ name="Test Compliance",
+ version="1.0",
+ description="Test description",
+ )
+ assert data.tenant_id == "tenant-123"
+ assert data.framework == "Test"
+ assert data.requirements == []
+
+ def test_compliance_data_with_requirements(self):
+ """Test ComplianceData with requirements list."""
+ reqs = [
+ RequirementData(id="R1", description="Req 1", status="PASS"),
+ RequirementData(id="R2", description="Req 2", status="FAIL"),
+ ]
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="s1",
+ provider_id="p1",
+ compliance_id="c1",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ requirements=reqs,
+ )
+ assert len(data.requirements) == 2
+ assert data.requirements[0].id == "R1"
+
+ def test_compliance_data_with_attributes(self):
+ """Test ComplianceData with attributes dictionary."""
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="s1",
+ provider_id="p1",
+ compliance_id="c1",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ attributes_by_requirement_id={
+ "R1": {"attributes": {"key": "value"}},
+ },
+ )
+ assert "R1" in data.attributes_by_requirement_id
+ assert data.attributes_by_requirement_id["R1"]["attributes"]["key"] == "value"
+
+
+# =============================================================================
+# PDF Styles Tests
+# =============================================================================
+
+
+class TestPDFStyles:
+ """Tests for PDF styles."""
+
+ def test_create_pdf_styles_returns_dict(self):
+ """Test that create_pdf_styles returns a dictionary."""
+ styles = create_pdf_styles()
+ assert isinstance(styles, dict)
+
+ def test_create_pdf_styles_has_required_keys(self):
+ """Test that styles dict has all required keys."""
+ styles = create_pdf_styles()
+ required_keys = ["title", "h1", "h2", "h3", "normal", "normal_center"]
+ for key in required_keys:
+ assert key in styles
+
+ def test_create_pdf_styles_caches_result(self):
+ """Test that styles are cached."""
+ styles1 = create_pdf_styles()
+ styles2 = create_pdf_styles()
+ assert styles1 is styles2
+
+
+# =============================================================================
+# Base Generator Tests
+# =============================================================================
+
+
+class TestBaseComplianceReportGenerator:
+ """Tests for BaseComplianceReportGenerator."""
+
+ def test_cannot_instantiate_directly(self):
+ """Test that base class cannot be instantiated directly."""
+ config = FrameworkConfig(name="test", display_name="Test")
+ with pytest.raises(TypeError):
+ BaseComplianceReportGenerator(config)
+
+ def test_concrete_implementation(self):
+ """Test that a concrete implementation can be created."""
+
+ class ConcreteGenerator(BaseComplianceReportGenerator):
+ def create_executive_summary(self, data):
+ return []
+
+ def create_charts_section(self, data):
+ return []
+
+ def create_requirements_index(self, data):
+ return []
+
+ config = FrameworkConfig(name="test", display_name="Test")
+ generator = ConcreteGenerator(config)
+ assert generator.config.name == "test"
+ assert generator.styles is not None
+
+ def test_get_footer_text_english(self):
+ """Test footer text in English."""
+
+ class ConcreteGenerator(BaseComplianceReportGenerator):
+ def create_executive_summary(self, data):
+ return []
+
+ def create_charts_section(self, data):
+ return []
+
+ def create_requirements_index(self, data):
+ return []
+
+ config = FrameworkConfig(name="test", display_name="Test", language="en")
+ generator = ConcreteGenerator(config)
+ left, right = generator.get_footer_text(1)
+ assert left == "Page 1"
+ assert right == "Powered by Prowler"
+
+ def test_get_footer_text_spanish(self):
+ """Test footer text in Spanish."""
+
+ class ConcreteGenerator(BaseComplianceReportGenerator):
+ def create_executive_summary(self, data):
+ return []
+
+ def create_charts_section(self, data):
+ return []
+
+ def create_requirements_index(self, data):
+ return []
+
+ config = FrameworkConfig(name="test", display_name="Test", language="es")
+ generator = ConcreteGenerator(config)
+ left, right = generator.get_footer_text(1)
+ assert left == "PΓ‘gina 1"
+
+
+class TestBuildInfoRows:
+ """Tests for _build_info_rows helper method."""
+
+ def _create_generator(self, language="en"):
+ """Create a concrete generator for testing."""
+
+ class ConcreteGenerator(BaseComplianceReportGenerator):
+ def create_executive_summary(self, data):
+ return []
+
+ def create_charts_section(self, data):
+ return []
+
+ def create_requirements_index(self, data):
+ return []
+
+ config = FrameworkConfig(name="test", display_name="Test", language=language)
+ return ConcreteGenerator(config)
+
+ def test_build_info_rows_english(self):
+ """Test info rows are built with English labels."""
+ generator = self._create_generator(language="en")
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="scan-123",
+ provider_id="p1",
+ compliance_id="test_compliance",
+ framework="Test Framework",
+ name="Test Name",
+ version="1.0",
+ description="Test description",
+ )
+
+ rows = generator._build_info_rows(data, language="en")
+
+ assert ("Framework:", "Test Framework") in rows
+ assert ("Name:", "Test Name") in rows
+ assert ("Version:", "1.0") in rows
+ assert ("Scan ID:", "scan-123") in rows
+ assert ("Description:", "Test description") in rows
+
+ def test_build_info_rows_spanish(self):
+ """Test info rows are built with Spanish labels."""
+ generator = self._create_generator(language="es")
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="scan-123",
+ provider_id="p1",
+ compliance_id="test_compliance",
+ framework="Test Framework",
+ name="Test Name",
+ version="1.0",
+ description="Test description",
+ )
+
+ rows = generator._build_info_rows(data, language="es")
+
+ assert ("Framework:", "Test Framework") in rows
+ assert ("Nombre:", "Test Name") in rows
+ assert ("VersiΓ³n:", "1.0") in rows
+ assert ("Scan ID:", "scan-123") in rows
+ assert ("DescripciΓ³n:", "Test description") in rows
+
+ def test_build_info_rows_with_provider(self):
+ """Test info rows include provider info when available."""
+ from unittest.mock import Mock
+
+ generator = self._create_generator(language="en")
+
+ mock_provider = Mock()
+ mock_provider.provider = "aws"
+ mock_provider.uid = "123456789012"
+ mock_provider.alias = "my-account"
+
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="scan-123",
+ provider_id="p1",
+ compliance_id="test_compliance",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ provider_obj=mock_provider,
+ )
+
+ rows = generator._build_info_rows(data, language="en")
+
+ assert ("Provider:", "AWS") in rows
+ assert ("Account ID:", "123456789012") in rows
+ assert ("Alias:", "my-account") in rows
+
+ def test_build_info_rows_with_provider_spanish(self):
+ """Test provider info uses Spanish labels."""
+ from unittest.mock import Mock
+
+ generator = self._create_generator(language="es")
+
+ mock_provider = Mock()
+ mock_provider.provider = "azure"
+ mock_provider.uid = "subscription-id"
+ mock_provider.alias = "mi-suscripcion"
+
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="scan-123",
+ provider_id="p1",
+ compliance_id="test_compliance",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ provider_obj=mock_provider,
+ )
+
+ rows = generator._build_info_rows(data, language="es")
+
+ assert ("Proveedor:", "AZURE") in rows
+ assert ("Account ID:", "subscription-id") in rows
+ assert ("Alias:", "mi-suscripcion") in rows
+
+ def test_build_info_rows_without_provider(self):
+ """Test info rows work without provider info."""
+ generator = self._create_generator(language="en")
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="scan-123",
+ provider_id="p1",
+ compliance_id="test_compliance",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ provider_obj=None,
+ )
+
+ rows = generator._build_info_rows(data, language="en")
+
+ # Provider info should not be present
+ labels = [label for label, _ in rows]
+ assert "Provider:" not in labels
+ assert "Account ID:" not in labels
+ assert "Alias:" not in labels
+
+ def test_build_info_rows_provider_with_missing_fields(self):
+ """Test provider info handles None values gracefully."""
+ from unittest.mock import Mock
+
+ generator = self._create_generator(language="en")
+
+ mock_provider = Mock()
+ mock_provider.provider = "gcp"
+ mock_provider.uid = None
+ mock_provider.alias = None
+
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="scan-123",
+ provider_id="p1",
+ compliance_id="test_compliance",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ provider_obj=mock_provider,
+ )
+
+ rows = generator._build_info_rows(data, language="en")
+
+ assert ("Provider:", "GCP") in rows
+ assert ("Account ID:", "N/A") in rows
+ assert ("Alias:", "N/A") in rows
+
+ def test_build_info_rows_without_description(self):
+ """Test info rows exclude description when empty."""
+ generator = self._create_generator(language="en")
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="scan-123",
+ provider_id="p1",
+ compliance_id="test_compliance",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ )
+
+ rows = generator._build_info_rows(data, language="en")
+
+ labels = [label for label, _ in rows]
+ assert "Description:" not in labels
+
+ def test_build_info_rows_defaults_to_english(self):
+ """Test unknown language defaults to English labels."""
+ generator = self._create_generator(language="en")
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="scan-123",
+ provider_id="p1",
+ compliance_id="test_compliance",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="Desc",
+ )
+
+ rows = generator._build_info_rows(data, language="fr") # Unknown language
+
+ # Should use English labels as fallback
+ assert ("Name:", "Test") in rows
+ assert ("Description:", "Desc") in rows
+
+
+# =============================================================================
+# Integration Tests
+# =============================================================================
+
+
+class TestExampleReportGenerator:
+ """Integration tests using an example report generator."""
+
+ def setup_method(self):
+ """Set up test fixtures."""
+
+ class ExampleGenerator(BaseComplianceReportGenerator):
+ """Example concrete implementation for testing."""
+
+ def create_executive_summary(self, data):
+ return [
+ Paragraph("Executive Summary", self.styles["h1"]),
+ Paragraph(
+ f"Total requirements: {len(data.requirements)}",
+ self.styles["normal"],
+ ),
+ ]
+
+ def create_charts_section(self, data):
+ chart_buffer = create_vertical_bar_chart(
+ labels=["Pass", "Fail"],
+ values=[80, 20],
+ )
+ return [Image(chart_buffer, width=6 * inch, height=4 * inch)]
+
+ def create_requirements_index(self, data):
+ elements = [Paragraph("Requirements Index", self.styles["h1"])]
+ for req in data.requirements:
+ elements.append(
+ Paragraph(
+ f"- {req.id}: {req.description}", self.styles["normal"]
+ )
+ )
+ return elements
+
+ self.generator_class = ExampleGenerator
+
+ def test_example_generator_creation(self):
+ """Test creating example generator."""
+ config = FrameworkConfig(name="example", display_name="Example Framework")
+ generator = self.generator_class(config)
+ assert generator is not None
+
+ def test_example_generator_executive_summary(self):
+ """Test executive summary generation."""
+ config = FrameworkConfig(name="example", display_name="Example Framework")
+ generator = self.generator_class(config)
+
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="s1",
+ provider_id="p1",
+ compliance_id="c1",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ requirements=[
+ RequirementData(id="R1", description="Req 1", status="PASS"),
+ RequirementData(id="R2", description="Req 2", status="FAIL"),
+ ],
+ )
+
+ elements = generator.create_executive_summary(data)
+ assert len(elements) == 2
+
+ def test_example_generator_charts_section(self):
+ """Test charts section generation."""
+ config = FrameworkConfig(name="example", display_name="Example Framework")
+ generator = self.generator_class(config)
+
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="s1",
+ provider_id="p1",
+ compliance_id="c1",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ )
+
+ elements = generator.create_charts_section(data)
+ assert len(elements) == 1
+
+ def test_example_generator_requirements_index(self):
+ """Test requirements index generation."""
+ config = FrameworkConfig(name="example", display_name="Example Framework")
+ generator = self.generator_class(config)
+
+ data = ComplianceData(
+ tenant_id="t1",
+ scan_id="s1",
+ provider_id="p1",
+ compliance_id="c1",
+ framework="Test",
+ name="Test",
+ version="1.0",
+ description="",
+ requirements=[
+ RequirementData(id="R1", description="Requirement 1", status="PASS"),
+ ],
+ )
+
+ elements = generator.create_requirements_index(data)
+ assert len(elements) == 2 # Header + 1 requirement
+
+
+# =============================================================================
+# Edge Case Tests
+# =============================================================================
+
+
+class TestChartEdgeCases:
+ """Tests for chart edge cases."""
+
+ def test_vertical_bar_chart_empty_data(self):
+ """Test vertical bar chart with empty data."""
+ buffer = create_vertical_bar_chart(labels=[], values=[])
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_vertical_bar_chart_single_item(self):
+ """Test vertical bar chart with single item."""
+ buffer = create_vertical_bar_chart(labels=["Single"], values=[75.0])
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_horizontal_bar_chart_empty_data(self):
+ """Test horizontal bar chart with empty data."""
+ buffer = create_horizontal_bar_chart(labels=[], values=[])
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_horizontal_bar_chart_single_item(self):
+ """Test horizontal bar chart with single item."""
+ buffer = create_horizontal_bar_chart(labels=["Single"], values=[50.0])
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_radar_chart_minimum_points(self):
+ """Test radar chart with minimum number of points (3)."""
+ buffer = create_radar_chart(
+ labels=["A", "B", "C"],
+ values=[30.0, 60.0, 90.0],
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_pie_chart_single_slice(self):
+ """Test pie chart with single slice."""
+ buffer = create_pie_chart(labels=["Only"], values=[100.0])
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_pie_chart_many_slices(self):
+ """Test pie chart with many slices."""
+ labels = [f"Item {i}" for i in range(10)]
+ values = [10.0] * 10
+ buffer = create_pie_chart(labels=labels, values=values)
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_stacked_bar_chart_single_series(self):
+ """Test stacked bar chart with single series."""
+ buffer = create_stacked_bar_chart(
+ labels=["A", "B"],
+ data_series={"Only": [10.0, 20.0]},
+ )
+ assert isinstance(buffer, io.BytesIO)
+
+ def test_stacked_bar_chart_empty_data(self):
+ """Test stacked bar chart with empty data."""
+ buffer = create_stacked_bar_chart(labels=[], data_series={})
+ assert isinstance(buffer, io.BytesIO)
+
+
+class TestComponentEdgeCases:
+ """Tests for component edge cases."""
+
+ def test_create_badge_empty_text(self):
+ """Test badge with empty text."""
+ badge = create_badge("", COLOR_BLUE)
+ assert badge is not None
+
+ def test_create_badge_long_text(self):
+ """Test badge with very long text."""
+ long_text = "A" * 100
+ badge = create_badge(long_text, COLOR_BLUE, width=5 * inch)
+ assert badge is not None
+
+ def test_create_status_badge_unknown_status(self):
+ """Test status badge with unknown status."""
+ badge = create_status_badge("UNKNOWN")
+ assert badge is not None
+
+ def test_create_multi_badge_row_single_badge(self):
+ """Test multi-badge row with single badge."""
+ badges = [("A", COLOR_BLUE)]
+ table = create_multi_badge_row(badges)
+ assert table is not None
+
+ def test_create_multi_badge_row_many_badges(self):
+ """Test multi-badge row with many badges."""
+ badges = [(chr(65 + i), COLOR_BLUE) for i in range(10)] # A-J
+ table = create_multi_badge_row(badges)
+ assert table is not None
+
+ def test_create_info_table_empty(self):
+ """Test info table with empty rows."""
+ table = create_info_table([])
+ assert isinstance(table, Table)
+
+ def test_create_info_table_long_values(self):
+ """Test info table with very long values wraps properly."""
+ rows = [
+ ("Key:", "A" * 200), # Very long value
+ ]
+ styles = create_pdf_styles()
+ table = create_info_table(rows, normal_style=styles["normal"])
+ assert table is not None
+
+ def test_create_data_table_empty(self):
+ """Test data table with empty data."""
+ columns = [
+ ColumnConfig("Name", 2 * inch, "name"),
+ ]
+ table = create_data_table([], columns)
+ assert table is not None
+
+ def test_create_data_table_large_dataset(self):
+ """Test data table with large dataset uses LongTable."""
+ # Create more than 50 rows to trigger LongTable
+ data = [{"name": f"Item {i}"} for i in range(60)]
+ columns = [ColumnConfig("Name", 2 * inch, "name")]
+ table = create_data_table(data, columns)
+ # Should be a LongTable for large datasets
+ assert isinstance(table, LongTable)
+
+ def test_create_risk_component_zero_values(self):
+ """Test risk component with zero values."""
+ component = create_risk_component(risk_level=0, weight=0, score=0)
+ assert component is not None
+
+ def test_create_risk_component_max_values(self):
+ """Test risk component with maximum values."""
+ component = create_risk_component(risk_level=5, weight=200, score=1000)
+ assert component is not None
+
+
+class TestColorEdgeCases:
+ """Tests for color function edge cases."""
+
+ def test_get_color_for_compliance_boundary_80(self):
+ """Test compliance color at exactly 80%."""
+ assert get_color_for_compliance(80) == COLOR_SAFE
+
+ def test_get_color_for_compliance_boundary_60(self):
+ """Test compliance color at exactly 60%."""
+ assert get_color_for_compliance(60) == COLOR_LOW_RISK
+
+ def test_get_color_for_compliance_over_100(self):
+ """Test compliance color for values over 100."""
+ assert get_color_for_compliance(150) == COLOR_SAFE
+
+ def test_get_color_for_weight_boundary_100(self):
+ """Test weight color at exactly 100."""
+ assert get_color_for_weight(100) == COLOR_LOW_RISK
+
+ def test_get_color_for_weight_boundary_50(self):
+ """Test weight color at exactly 50."""
+ assert get_color_for_weight(50) == COLOR_SAFE
+
+ def test_get_status_color_case_insensitive(self):
+ """Test that status color is case insensitive."""
+ assert get_status_color("PASS") == get_status_color("pass")
+ assert get_status_color("FAIL") == get_status_color("Fail")
+ assert get_status_color("MANUAL") == get_status_color("manual")
+
+
+class TestFrameworkConfigEdgeCases:
+ """Tests for FrameworkConfig edge cases."""
+
+ def test_framework_config_empty_sections(self):
+ """Test FrameworkConfig with empty sections list."""
+ config = FrameworkConfig(
+ name="test",
+ display_name="Test",
+ sections=[],
+ )
+ assert config.sections == []
+
+ def test_framework_config_empty_attribute_fields(self):
+ """Test FrameworkConfig with empty attribute fields."""
+ config = FrameworkConfig(
+ name="test",
+ display_name="Test",
+ attribute_fields=[],
+ )
+ assert config.attribute_fields == []
+
+ def test_get_framework_config_case_variations(self):
+ """Test get_framework_config with different case variations."""
+ # Test case insensitivity
+ assert get_framework_config("PROWLER_THREATSCORE_AWS") is not None
+ assert get_framework_config("ENS_RD2022_AWS") is not None
+ assert get_framework_config("NIS2_AWS") is not None
+
+ def test_get_framework_config_partial_match(self):
+ """Test that partial matches work correctly."""
+ # Should match based on substring
+ assert get_framework_config("my_custom_threatscore_compliance") is not None
+ assert get_framework_config("ens_something_else") is not None
+ assert get_framework_config("nis2_gcp") is not None
diff --git a/api/src/backend/tasks/tests/test_reports_ens.py b/api/src/backend/tasks/tests/test_reports_ens.py
new file mode 100644
index 0000000000..91eb6d6f3a
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_reports_ens.py
@@ -0,0 +1,1227 @@
+import io
+from unittest.mock import Mock, patch
+
+import pytest
+from reportlab.platypus import PageBreak, Paragraph, Table
+from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
+from tasks.jobs.reports.ens import ENSReportGenerator
+
+
+# Use string status values directly to avoid Django DB initialization
+# These match api.models.StatusChoices values
+class StatusChoices:
+ """Mock StatusChoices to avoid Django DB initialization."""
+
+ PASS = "PASS"
+ FAIL = "FAIL"
+ MANUAL = "MANUAL"
+
+
+# =============================================================================
+# Fixtures
+# =============================================================================
+
+
+@pytest.fixture
+def ens_generator():
+ """Create an ENSReportGenerator instance for testing."""
+ config = FRAMEWORK_REGISTRY["ens"]
+ return ENSReportGenerator(config)
+
+
+@pytest.fixture
+def mock_ens_requirement_attribute():
+ """Create a mock ENS requirement attribute with all fields."""
+ mock = Mock()
+ mock.Marco = "Operacional"
+ mock.Categoria = "GestiΓ³n de incidentes"
+ mock.DescripcionControl = "Control de gestiΓ³n de incidentes de seguridad"
+ mock.Tipo = "requisito"
+ mock.Nivel = "alto"
+ mock.Dimensiones = ["confidencialidad", "integridad"]
+ mock.ModoEjecucion = "automatico"
+ mock.IdGrupoControl = "op.ext.1"
+ return mock
+
+
+@pytest.fixture
+def mock_ens_requirement_attribute_medio():
+ """Create a mock ENS requirement attribute with nivel medio."""
+ mock = Mock()
+ mock.Marco = "Organizativo"
+ mock.Categoria = "Seguridad en los recursos humanos"
+ mock.DescripcionControl = "Control de seguridad del personal"
+ mock.Tipo = "refuerzo"
+ mock.Nivel = "medio"
+ mock.Dimensiones = "trazabilidad, autenticidad" # String format
+ mock.ModoEjecucion = "manual"
+ mock.IdGrupoControl = "org.rh.1"
+ return mock
+
+
+@pytest.fixture
+def mock_ens_requirement_attribute_bajo():
+ """Create a mock ENS requirement attribute with nivel bajo."""
+ mock = Mock()
+ mock.Marco = "Medidas de ProtecciΓ³n"
+ mock.Categoria = "ProtecciΓ³n de las instalaciones"
+ mock.DescripcionControl = "Control de acceso fΓsico"
+ mock.Tipo = "recomendacion"
+ mock.Nivel = "bajo"
+ mock.Dimensiones = ["disponibilidad"]
+ mock.ModoEjecucion = "automatico"
+ mock.IdGrupoControl = "mp.if.1"
+ return mock
+
+
+@pytest.fixture
+def mock_ens_requirement_attribute_opcional():
+ """Create a mock ENS requirement attribute with nivel opcional."""
+ mock = Mock()
+ mock.Marco = "Marco de OrganizaciΓ³n"
+ mock.Categoria = "PolΓtica de seguridad"
+ mock.DescripcionControl = "PolΓtica de seguridad de la informaciΓ³n"
+ mock.Tipo = "medida"
+ mock.Nivel = "opcional"
+ mock.Dimensiones = []
+ mock.ModoEjecucion = "automatico"
+ mock.IdGrupoControl = "org.1"
+ return mock
+
+
+@pytest.fixture
+def basic_ens_compliance_data():
+ """Create basic ComplianceData for ENS testing."""
+ return ComplianceData(
+ tenant_id="tenant-123",
+ scan_id="scan-456",
+ provider_id="provider-789",
+ compliance_id="ens_rd2022_aws",
+ framework="ENS RD2022",
+ name="Esquema Nacional de Seguridad RD 311/2022",
+ version="2022",
+ description="Marco de seguridad para la administraciΓ³n electrΓ³nica espaΓ±ola",
+ )
+
+
+# =============================================================================
+# Generator Initialization Tests
+# =============================================================================
+
+
+class TestENSGeneratorInitialization:
+ """Test suite for ENS generator initialization."""
+
+ def test_generator_creation(self, ens_generator):
+ """Test that ENS generator is created correctly."""
+ assert ens_generator is not None
+ assert ens_generator.config.name == "ens"
+ assert ens_generator.config.language == "es"
+
+ def test_generator_has_niveles(self, ens_generator):
+ """Test that ENS config has niveles enabled."""
+ assert ens_generator.config.has_niveles is True
+
+ def test_generator_has_dimensions(self, ens_generator):
+ """Test that ENS config has dimensions enabled."""
+ assert ens_generator.config.has_dimensions is True
+
+ def test_generator_no_risk_levels(self, ens_generator):
+ """Test that ENS config does not use risk levels."""
+ assert ens_generator.config.has_risk_levels is False
+
+ def test_generator_no_weight(self, ens_generator):
+ """Test that ENS config does not use weight."""
+ assert ens_generator.config.has_weight is False
+
+
+# =============================================================================
+# Cover Page Tests
+# =============================================================================
+
+
+class TestENSCoverPage:
+ """Test suite for ENS cover page generation."""
+
+ @patch("tasks.jobs.reports.ens.Image")
+ def test_cover_page_has_logos(
+ self, mock_image, ens_generator, basic_ens_compliance_data
+ ):
+ """Test that cover page contains logos."""
+ basic_ens_compliance_data.requirements = []
+ basic_ens_compliance_data.attributes_by_requirement_id = {}
+
+ elements = ens_generator.create_cover_page(basic_ens_compliance_data)
+
+ assert len(elements) > 0
+ # Should have called Image at least twice (prowler + ens logos)
+ assert mock_image.call_count >= 2
+
+ def test_cover_page_has_title(self, ens_generator, basic_ens_compliance_data):
+ """Test that cover page contains the ENS title."""
+ basic_ens_compliance_data.requirements = []
+ basic_ens_compliance_data.attributes_by_requirement_id = {}
+
+ elements = ens_generator.create_cover_page(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "ENS" in content or "Informe" in content
+
+ def test_cover_page_has_info_table(self, ens_generator, basic_ens_compliance_data):
+ """Test that cover page contains info table with metadata."""
+ basic_ens_compliance_data.requirements = []
+ basic_ens_compliance_data.attributes_by_requirement_id = {}
+
+ elements = ens_generator.create_cover_page(basic_ens_compliance_data)
+
+ tables = [e for e in elements if isinstance(e, Table)]
+ assert len(tables) >= 1 # At least info table
+
+ def test_cover_page_has_warning_about_manual(
+ self, ens_generator, basic_ens_compliance_data
+ ):
+ """Test that cover page has warning about manual requirements."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Manual requirement",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ )
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {}
+
+ elements = ens_generator.create_cover_page(basic_ens_compliance_data)
+
+ # Find paragraphs (including those inside tables) that mention manual
+ all_paragraphs = []
+ for e in elements:
+ if isinstance(e, Paragraph):
+ all_paragraphs.append(e)
+ elif isinstance(e, Table):
+ # Check table cells for Paragraph objects
+ cell_values = getattr(e, "_cellvalues", [])
+ for row in cell_values:
+ for cell in row:
+ if isinstance(cell, Paragraph):
+ all_paragraphs.append(cell)
+ content = " ".join(str(p.text) for p in all_paragraphs)
+ assert "manual" in content.lower() or "AVISO" in content
+
+ def test_cover_page_has_legend(self, ens_generator, basic_ens_compliance_data):
+ """Test that cover page contains the ENS values legend."""
+ basic_ens_compliance_data.requirements = []
+ basic_ens_compliance_data.attributes_by_requirement_id = {}
+
+ elements = ens_generator.create_cover_page(basic_ens_compliance_data)
+
+ # Legend should be a table with explanations
+ tables = [e for e in elements if isinstance(e, Table)]
+ # At least 3 tables: logos, info, warning, legend
+ assert len(tables) >= 3
+
+
+# =============================================================================
+# Executive Summary Tests
+# =============================================================================
+
+
+class TestENSExecutiveSummary:
+ """Test suite for ENS executive summary generation."""
+
+ def test_executive_summary_has_title(
+ self, ens_generator, basic_ens_compliance_data
+ ):
+ """Test that executive summary has Spanish title."""
+ basic_ens_compliance_data.requirements = []
+ basic_ens_compliance_data.attributes_by_requirement_id = {}
+
+ elements = ens_generator.create_executive_summary(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Resumen Ejecutivo" in content
+
+ def test_executive_summary_calculates_compliance(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that executive summary calculates compliance percentage."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Failed requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_executive_summary(basic_ens_compliance_data)
+
+ # Should contain tables with metrics
+ tables = [e for e in elements if isinstance(e, Table)]
+ assert len(tables) >= 1
+
+ def test_executive_summary_excludes_manual_from_compliance(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that manual requirements are excluded from compliance calculation."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Manual requirement",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_executive_summary(basic_ens_compliance_data)
+
+ # Should calculate 100% compliance (only 1 auto requirement that passed)
+ assert len(elements) > 0
+
+ def test_executive_summary_has_nivel_table(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that executive summary includes compliance by nivel table."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Alto requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_executive_summary(basic_ens_compliance_data)
+
+ # Should have nivel table
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Nivel" in content or "nivel" in content.lower()
+
+
+# =============================================================================
+# Charts Section Tests
+# =============================================================================
+
+
+class TestENSChartsSection:
+ """Test suite for ENS charts section generation."""
+
+ def test_charts_section_has_page_breaks(
+ self, ens_generator, basic_ens_compliance_data
+ ):
+ """Test that charts section has page breaks between charts."""
+ basic_ens_compliance_data.requirements = []
+ basic_ens_compliance_data.attributes_by_requirement_id = {}
+
+ elements = ens_generator.create_charts_section(basic_ens_compliance_data)
+
+ page_breaks = [e for e in elements if isinstance(e, PageBreak)]
+ assert len(page_breaks) >= 2 # At least 2 page breaks for different charts
+
+ def test_charts_section_has_marco_category_chart(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that charts section contains Marco/CategorΓa chart."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_charts_section(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Marco" in content or "CategorΓa" in content
+
+ def test_charts_section_has_dimensions_radar(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that charts section contains dimensions radar chart."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_charts_section(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Dimensiones" in content or "dimensiones" in content.lower()
+
+ def test_charts_section_has_tipo_distribution(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that charts section contains tipo distribution."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_charts_section(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Tipo" in content or "tipo" in content.lower()
+
+
+# =============================================================================
+# Critical Failed Requirements Tests
+# =============================================================================
+
+
+class TestENSCriticalFailedRequirements:
+ """Test suite for ENS critical failed requirements (nivel alto)."""
+
+ def test_no_critical_failures_shows_success_message(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that no critical failures shows success message."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed alto requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator._create_critical_failed_section(
+ basic_ens_compliance_data
+ )
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "No hay" in content or "β
" in content
+
+ def test_critical_failures_shows_table(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that critical failures shows requirements table."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Failed alto requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator._create_critical_failed_section(
+ basic_ens_compliance_data
+ )
+
+ tables = [e for e in elements if isinstance(e, Table)]
+ assert len(tables) >= 1
+
+ def test_critical_failures_only_includes_alto(
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ mock_ens_requirement_attribute_medio,
+ ):
+ """Test that only nivel alto failures are included."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Failed alto requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Failed medio requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=5,
+ total_findings=5,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]}
+ },
+ }
+
+ elements = ens_generator._create_critical_failed_section(
+ basic_ens_compliance_data
+ )
+
+ # Should have table but only with alto requirement
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ # Should mention 1 critical requirement
+ assert "1" in content
+
+
+# =============================================================================
+# Requirements Index Tests
+# =============================================================================
+
+
+class TestENSRequirementsIndex:
+ """Test suite for ENS requirements index generation."""
+
+ def test_requirements_index_has_title(
+ self, ens_generator, basic_ens_compliance_data
+ ):
+ """Test that requirements index has Spanish title."""
+ basic_ens_compliance_data.requirements = []
+ basic_ens_compliance_data.attributes_by_requirement_id = {}
+
+ elements = ens_generator.create_requirements_index(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Γndice" in content or "Requisitos" in content
+
+ def test_requirements_index_organized_by_marco(
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ mock_ens_requirement_attribute_medio,
+ ):
+ """Test that requirements index is organized by Marco."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Operacional requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Organizativo requirement",
+ status=StatusChoices.PASS,
+ passed_findings=5,
+ failed_findings=0,
+ total_findings=5,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]}
+ },
+ }
+
+ elements = ens_generator.create_requirements_index(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert (
+ "Operacional" in content or "Organizativo" in content or "Marco" in content
+ )
+
+ def test_requirements_index_excludes_manual(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that manual requirements are excluded from index."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Auto requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Manual requirement",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_requirements_index(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ # REQ-001 should be there, REQ-002 should not
+ assert "REQ-001" in content
+ assert "REQ-002" not in content
+
+ def test_requirements_index_shows_status_indicators(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that requirements index shows pass/fail indicators."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Failed requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_requirements_index(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ # Should have status indicators
+ assert "β" in content or "β" in content
+
+
+# =============================================================================
+# Detailed Findings Tests
+# =============================================================================
+
+
+class TestENSDetailedFindings:
+ """Test suite for ENS detailed findings generation."""
+
+ def test_detailed_findings_has_title(
+ self, ens_generator, basic_ens_compliance_data
+ ):
+ """Test that detailed findings section has title."""
+ basic_ens_compliance_data.requirements = []
+ basic_ens_compliance_data.attributes_by_requirement_id = {}
+
+ elements = ens_generator.create_detailed_findings(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Detalle" in content or "Requisitos" in content
+
+ def test_detailed_findings_no_failures_message(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test message when no failed requirements exist."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_detailed_findings(basic_ens_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "No hay" in content or "requisitos fallidos" in content.lower()
+
+ def test_detailed_findings_shows_failed_requirements(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that failed requirements are shown in detail."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Failed requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_detailed_findings(basic_ens_compliance_data)
+
+ # Should have tables showing requirement details
+ tables = [e for e in elements if isinstance(e, Table)]
+ assert len(tables) >= 1
+
+ def test_detailed_findings_shows_nivel_badges(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that detailed findings show nivel badges."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Failed requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_detailed_findings(basic_ens_compliance_data)
+
+ # Should generate without errors
+ assert len(elements) > 0
+
+ def test_detailed_findings_shows_dimensiones_badges(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that detailed findings show dimension badges."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Failed requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator.create_detailed_findings(basic_ens_compliance_data)
+
+ # Should generate without errors with dimension badges
+ assert len(elements) > 0
+
+
+# =============================================================================
+# Dimension Handling Tests
+# =============================================================================
+
+
+class TestENSDimensionHandling:
+ """Test suite for ENS security dimension handling."""
+
+ def test_dimensions_as_list(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test handling dimensions as a list."""
+ # mock_ens_requirement_attribute has Dimensiones as list
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ # Should not raise any errors
+ chart_buffer = ens_generator._create_dimensions_radar_chart(
+ basic_ens_compliance_data
+ )
+ assert isinstance(chart_buffer, io.BytesIO)
+
+ def test_dimensions_as_string(
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute_medio,
+ ):
+ """Test handling dimensions as comma-separated string."""
+ # mock_ens_requirement_attribute_medio has Dimensiones as string
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]}
+ },
+ }
+
+ # Should not raise any errors
+ chart_buffer = ens_generator._create_dimensions_radar_chart(
+ basic_ens_compliance_data
+ )
+ assert isinstance(chart_buffer, io.BytesIO)
+
+ def test_dimensions_empty(
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute_opcional,
+ ):
+ """Test handling empty dimensions."""
+ # mock_ens_requirement_attribute_opcional has empty Dimensiones
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_ens_requirement_attribute_opcional]
+ }
+ },
+ }
+
+ # Should not raise any errors
+ chart_buffer = ens_generator._create_dimensions_radar_chart(
+ basic_ens_compliance_data
+ )
+ assert isinstance(chart_buffer, io.BytesIO)
+
+
+# =============================================================================
+# Footer Tests
+# =============================================================================
+
+
+class TestENSFooter:
+ """Test suite for ENS footer generation."""
+
+ def test_footer_is_spanish(self, ens_generator):
+ """Test that footer text is in Spanish."""
+ left, right = ens_generator.get_footer_text(1)
+
+ assert "PΓ‘gina" in left
+ assert "Prowler" in right
+
+ def test_footer_includes_page_number(self, ens_generator):
+ """Test that footer includes page number."""
+ left, right = ens_generator.get_footer_text(5)
+
+ assert "5" in left
+
+
+# =============================================================================
+# Nivel Table Tests
+# =============================================================================
+
+
+class TestENSNivelTable:
+ """Test suite for ENS nivel compliance table."""
+
+ def test_nivel_table_all_niveles(
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ mock_ens_requirement_attribute_medio,
+ mock_ens_requirement_attribute_bajo,
+ mock_ens_requirement_attribute_opcional,
+ ):
+ """Test nivel table with all niveles represented."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Alto requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Medio requirement",
+ status=StatusChoices.PASS,
+ passed_findings=5,
+ failed_findings=0,
+ total_findings=5,
+ ),
+ RequirementData(
+ id="REQ-003",
+ description="Bajo requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=5,
+ total_findings=5,
+ ),
+ RequirementData(
+ id="REQ-004",
+ description="Opcional requirement",
+ status=StatusChoices.PASS,
+ passed_findings=3,
+ failed_findings=0,
+ total_findings=3,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]}
+ },
+ "REQ-003": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute_bajo]}
+ },
+ "REQ-004": {
+ "attributes": {
+ "req_attributes": [mock_ens_requirement_attribute_opcional]
+ }
+ },
+ }
+
+ elements = ens_generator._create_nivel_table(basic_ens_compliance_data)
+
+ # Should have at least one table
+ tables = [e for e in elements if isinstance(e, Table)]
+ assert len(tables) >= 1
+
+ def test_nivel_table_excludes_manual(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that manual requirements are excluded from nivel table."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Auto requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Manual requirement",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator._create_nivel_table(basic_ens_compliance_data)
+
+ # Should generate without errors
+ assert len(elements) > 0
+
+
+# =============================================================================
+# Marco Category Chart Tests
+# =============================================================================
+
+
+class TestENSMarcoCategoryChart:
+ """Test suite for ENS Marco/CategorΓa chart."""
+
+ def test_marco_category_chart_creation(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that Marco/CategorΓa chart is created successfully."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ chart_buffer = ens_generator._create_marco_category_chart(
+ basic_ens_compliance_data
+ )
+
+ assert isinstance(chart_buffer, io.BytesIO)
+ assert chart_buffer.getvalue() # Not empty
+
+ def test_marco_category_chart_excludes_manual(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that manual requirements are excluded from chart."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Auto requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Manual requirement",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ # Should not raise any errors
+ chart_buffer = ens_generator._create_marco_category_chart(
+ basic_ens_compliance_data
+ )
+ assert isinstance(chart_buffer, io.BytesIO)
+
+
+# =============================================================================
+# Tipo Section Tests
+# =============================================================================
+
+
+class TestENSTipoSection:
+ """Test suite for ENS tipo distribution section."""
+
+ def test_tipo_section_creation(
+ self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ ):
+ """Test that tipo section is created successfully."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Requisito type",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ }
+
+ elements = ens_generator._create_tipo_section(basic_ens_compliance_data)
+
+ assert len(elements) > 0
+ # Should have a table with tipo distribution
+ tables = [e for e in elements if isinstance(e, Table)]
+ assert len(tables) >= 1
+
+ def test_tipo_section_all_types(
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ mock_ens_requirement_attribute_medio,
+ mock_ens_requirement_attribute_bajo,
+ mock_ens_requirement_attribute_opcional,
+ ):
+ """Test tipo section with all requirement types."""
+ basic_ens_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Requisito type",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Refuerzo type",
+ status=StatusChoices.PASS,
+ passed_findings=5,
+ failed_findings=0,
+ total_findings=5,
+ ),
+ RequirementData(
+ id="REQ-003",
+ description="Recomendacion type",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=5,
+ total_findings=5,
+ ),
+ RequirementData(
+ id="REQ-004",
+ description="Medida type",
+ status=StatusChoices.PASS,
+ passed_findings=3,
+ failed_findings=0,
+ total_findings=3,
+ ),
+ ]
+ basic_ens_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute]}
+ },
+ "REQ-002": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]}
+ },
+ "REQ-003": {
+ "attributes": {"req_attributes": [mock_ens_requirement_attribute_bajo]}
+ },
+ "REQ-004": {
+ "attributes": {
+ "req_attributes": [mock_ens_requirement_attribute_opcional]
+ }
+ },
+ }
+
+ elements = ens_generator._create_tipo_section(basic_ens_compliance_data)
+
+ # Should generate without errors
+ assert len(elements) > 0
diff --git a/api/src/backend/tasks/tests/test_reports_nis2.py b/api/src/backend/tasks/tests/test_reports_nis2.py
new file mode 100644
index 0000000000..07e88ec7ca
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_reports_nis2.py
@@ -0,0 +1,1093 @@
+import io
+from unittest.mock import Mock, patch
+
+import pytest
+from reportlab.platypus import PageBreak, Paragraph, Table
+from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
+from tasks.jobs.reports.nis2 import NIS2ReportGenerator, _extract_section_number
+
+
+# Use string status values directly to avoid Django DB initialization
+# These match api.models.StatusChoices values
+class StatusChoices:
+ """Mock StatusChoices to avoid Django DB initialization."""
+
+ PASS = "PASS"
+ FAIL = "FAIL"
+ MANUAL = "MANUAL"
+
+
+# =============================================================================
+# Fixtures
+# =============================================================================
+
+
+@pytest.fixture
+def nis2_generator():
+ """Create a NIS2ReportGenerator instance for testing."""
+ config = FRAMEWORK_REGISTRY["nis2"]
+ return NIS2ReportGenerator(config)
+
+
+@pytest.fixture
+def mock_nis2_requirement_attribute_section1():
+ """Create a mock NIS2 requirement attribute for Section 1."""
+ mock = Mock()
+ mock.Section = "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS"
+ mock.SubSection = "1.1 Policy establishment"
+ mock.Description = "Establish security policies for network and information systems"
+ return mock
+
+
+@pytest.fixture
+def mock_nis2_requirement_attribute_section2():
+ """Create a mock NIS2 requirement attribute for Section 2."""
+ mock = Mock()
+ mock.Section = "2 RISK MANAGEMENT"
+ mock.SubSection = "2.1 Risk assessment"
+ mock.Description = "Conduct risk assessments for critical infrastructure"
+ return mock
+
+
+@pytest.fixture
+def mock_nis2_requirement_attribute_section11():
+ """Create a mock NIS2 requirement attribute for Section 11."""
+ mock = Mock()
+ mock.Section = "11 ACCESS CONTROL"
+ mock.SubSection = "11.2 User access management"
+ mock.Description = "Manage user access to systems and data"
+ return mock
+
+
+@pytest.fixture
+def mock_nis2_requirement_attribute_no_subsection():
+ """Create a mock NIS2 requirement attribute without subsection."""
+ mock = Mock()
+ mock.Section = "3 INCIDENT HANDLING"
+ mock.SubSection = ""
+ mock.Description = "Handle security incidents effectively"
+ return mock
+
+
+@pytest.fixture
+def basic_nis2_compliance_data():
+ """Create basic ComplianceData for NIS2 testing."""
+ return ComplianceData(
+ tenant_id="tenant-123",
+ scan_id="scan-456",
+ provider_id="provider-789",
+ compliance_id="nis2_aws",
+ framework="NIS2",
+ name="NIS2 Directive (EU) 2022/2555",
+ version="2022",
+ description="EU directive on security of network and information systems",
+ )
+
+
+# =============================================================================
+# Section Number Extraction Tests
+# =============================================================================
+
+
+class TestSectionNumberExtraction:
+ """Test suite for section number extraction utility."""
+
+ def test_extract_simple_section_number(self):
+ """Test extracting single digit section number."""
+ result = _extract_section_number("1 POLICY ON SECURITY")
+ assert result == "1"
+
+ def test_extract_double_digit_section_number(self):
+ """Test extracting double digit section number."""
+ result = _extract_section_number("11 ACCESS CONTROL")
+ assert result == "11"
+
+ def test_extract_section_number_with_spaces(self):
+ """Test extracting section number with leading/trailing spaces."""
+ result = _extract_section_number(" 2 RISK MANAGEMENT ")
+ assert result == "2"
+
+ def test_extract_section_number_empty_string(self):
+ """Test extracting from empty string returns 'Other'."""
+ result = _extract_section_number("")
+ assert result == "Other"
+
+ def test_extract_section_number_none_like(self):
+ """Test extracting from empty/None-like returns 'Other'."""
+ # Note: The function expects str, so we test empty string behavior
+ result = _extract_section_number("")
+ assert result == "Other"
+
+ def test_extract_section_number_no_number(self):
+ """Test extracting from string without number returns 'Other'."""
+ result = _extract_section_number("POLICY ON SECURITY")
+ assert result == "Other"
+
+ def test_extract_section_number_letter_first(self):
+ """Test extracting from string starting with letter returns 'Other'."""
+ result = _extract_section_number("A. Some Section")
+ assert result == "Other"
+
+
+# =============================================================================
+# Generator Initialization Tests
+# =============================================================================
+
+
+class TestNIS2GeneratorInitialization:
+ """Test suite for NIS2 generator initialization."""
+
+ def test_generator_creation(self, nis2_generator):
+ """Test that NIS2 generator is created correctly."""
+ assert nis2_generator is not None
+ assert nis2_generator.config.name == "nis2"
+ assert nis2_generator.config.language == "en"
+
+ def test_generator_no_niveles(self, nis2_generator):
+ """Test that NIS2 config does not use niveles."""
+ assert nis2_generator.config.has_niveles is False
+
+ def test_generator_no_dimensions(self, nis2_generator):
+ """Test that NIS2 config does not use dimensions."""
+ assert nis2_generator.config.has_dimensions is False
+
+ def test_generator_no_risk_levels(self, nis2_generator):
+ """Test that NIS2 config does not use risk levels."""
+ assert nis2_generator.config.has_risk_levels is False
+
+ def test_generator_no_weight(self, nis2_generator):
+ """Test that NIS2 config does not use weight."""
+ assert nis2_generator.config.has_weight is False
+
+
+# =============================================================================
+# Cover Page Tests
+# =============================================================================
+
+
+class TestNIS2CoverPage:
+ """Test suite for NIS2 cover page generation."""
+
+ @patch("tasks.jobs.reports.nis2.Image")
+ def test_cover_page_has_logos(
+ self, mock_image, nis2_generator, basic_nis2_compliance_data
+ ):
+ """Test that cover page contains logos."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_cover_page(basic_nis2_compliance_data)
+
+ assert len(elements) > 0
+ # Should have called Image at least twice (prowler + nis2 logos)
+ assert mock_image.call_count >= 2
+
+ def test_cover_page_has_title(self, nis2_generator, basic_nis2_compliance_data):
+ """Test that cover page contains the NIS2 title."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_cover_page(basic_nis2_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "NIS2" in content or "Directive" in content
+
+ def test_cover_page_has_metadata_table(
+ self, nis2_generator, basic_nis2_compliance_data
+ ):
+ """Test that cover page contains metadata table."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_cover_page(basic_nis2_compliance_data)
+
+ tables = [e for e in elements if isinstance(e, Table)]
+ assert len(tables) >= 1
+
+
+# =============================================================================
+# Executive Summary Tests
+# =============================================================================
+
+
+class TestNIS2ExecutiveSummary:
+ """Test suite for NIS2 executive summary generation."""
+
+ def test_executive_summary_has_english_title(
+ self, nis2_generator, basic_nis2_compliance_data
+ ):
+ """Test that executive summary has English title."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Executive Summary" in content
+
+ def test_executive_summary_calculates_compliance(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test that executive summary calculates compliance percentage."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Failed requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-002": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ }
+
+ elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data)
+
+ # Should contain tables with metrics
+ tables = [e for e in elements if isinstance(e, Table)]
+ assert len(tables) >= 1
+
+ def test_executive_summary_shows_all_statuses(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test that executive summary shows passed, failed, and manual counts."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Failed",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-003",
+ description="Manual",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-002": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-003": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ }
+
+ elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data)
+
+ # Should have a summary table with all statuses
+ assert len(elements) > 0
+
+ def test_executive_summary_excludes_manual_from_percentage(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test that manual requirements are excluded from compliance percentage."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Manual",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-002": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ }
+
+ elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data)
+
+ # Should calculate 100% (only 1 evaluated requirement that passed)
+ assert len(elements) > 0
+
+
+# =============================================================================
+# Charts Section Tests
+# =============================================================================
+
+
+class TestNIS2ChartsSection:
+ """Test suite for NIS2 charts section generation."""
+
+ def test_charts_section_has_section_chart_title(
+ self, nis2_generator, basic_nis2_compliance_data
+ ):
+ """Test that charts section has section compliance title."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_charts_section(basic_nis2_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Section" in content or "Compliance" in content
+
+ def test_charts_section_has_page_break(
+ self, nis2_generator, basic_nis2_compliance_data
+ ):
+ """Test that charts section has page breaks."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_charts_section(basic_nis2_compliance_data)
+
+ page_breaks = [e for e in elements if isinstance(e, PageBreak)]
+ assert len(page_breaks) >= 1
+
+ def test_charts_section_has_subsection_breakdown(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test that charts section includes subsection breakdown table."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ }
+
+ elements = nis2_generator.create_charts_section(basic_nis2_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "SubSection" in content or "Breakdown" in content
+
+
+# =============================================================================
+# Section Chart Tests
+# =============================================================================
+
+
+class TestNIS2SectionChart:
+ """Test suite for NIS2 section compliance chart."""
+
+ def test_section_chart_creation(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test that section chart is created successfully."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ }
+
+ chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data)
+
+ assert isinstance(chart_buffer, io.BytesIO)
+ assert chart_buffer.getvalue() # Not empty
+
+ def test_section_chart_excludes_manual(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test that manual requirements are excluded from section chart."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Auto requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Manual requirement",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-002": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ }
+
+ # Should not raise any errors
+ chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data)
+ assert isinstance(chart_buffer, io.BytesIO)
+
+ def test_section_chart_multiple_sections(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ mock_nis2_requirement_attribute_section2,
+ mock_nis2_requirement_attribute_section11,
+ ):
+ """Test section chart with multiple sections."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Section 1 requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Section 2 requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-003",
+ description="Section 11 requirement",
+ status=StatusChoices.PASS,
+ passed_findings=5,
+ failed_findings=0,
+ total_findings=5,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-002": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section2]
+ }
+ },
+ "REQ-003": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section11]
+ }
+ },
+ }
+
+ chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data)
+ assert isinstance(chart_buffer, io.BytesIO)
+
+
+# =============================================================================
+# SubSection Table Tests
+# =============================================================================
+
+
+class TestNIS2SubSectionTable:
+ """Test suite for NIS2 subsection breakdown table."""
+
+ def test_subsection_table_creation(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test that subsection table is created successfully."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ }
+
+ table = nis2_generator._create_subsection_table(basic_nis2_compliance_data)
+
+ assert isinstance(table, Table)
+
+ def test_subsection_table_counts_statuses(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test that subsection table counts passed, failed, and manual."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Failed",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-003",
+ description="Manual",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-002": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-003": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ }
+
+ table = nis2_generator._create_subsection_table(basic_nis2_compliance_data)
+ assert isinstance(table, Table)
+
+ def test_subsection_table_no_subsection(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_no_subsection,
+ ):
+ """Test subsection table when requirements have no subsection."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="No subsection requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_no_subsection]
+ }
+ },
+ }
+
+ table = nis2_generator._create_subsection_table(basic_nis2_compliance_data)
+ assert isinstance(table, Table)
+
+
+# =============================================================================
+# Requirements Index Tests
+# =============================================================================
+
+
+class TestNIS2RequirementsIndex:
+ """Test suite for NIS2 requirements index generation."""
+
+ def test_requirements_index_has_title(
+ self, nis2_generator, basic_nis2_compliance_data
+ ):
+ """Test that requirements index has English title."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "Requirements Index" in content
+
+ def test_requirements_index_organized_by_section(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ mock_nis2_requirement_attribute_section2,
+ ):
+ """Test that requirements index is organized by section."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Section 1 requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Section 2 requirement",
+ status=StatusChoices.PASS,
+ passed_findings=5,
+ failed_findings=0,
+ total_findings=5,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-002": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section2]
+ }
+ },
+ }
+
+ elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ # Should have section headers
+ assert "Policy" in content or "Risk" in content or "1." in content
+
+ def test_requirements_index_shows_status_indicators(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test that requirements index shows pass/fail/manual indicators."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passed requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Failed requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-003",
+ description="Manual requirement",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-002": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-003": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ }
+
+ elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data)
+
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ # Should have status indicators
+ assert "β" in content or "β" in content or "β" in content
+
+ def test_requirements_index_truncates_long_descriptions(
+ self, nis2_generator, basic_nis2_compliance_data
+ ):
+ """Test that long descriptions are truncated."""
+ mock_attr = Mock()
+ mock_attr.Section = "1 POLICY"
+ mock_attr.SubSection = "1.1 Long subsection name"
+ mock_attr.Description = "A" * 100 # Very long description
+
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="A" * 100,
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr]}},
+ }
+
+ # Should not raise errors
+ elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data)
+ assert len(elements) > 0
+
+
+# =============================================================================
+# Section Key Sorting Tests
+# =============================================================================
+
+
+class TestNIS2SectionKeySorting:
+ """Test suite for NIS2 section key sorting."""
+
+ def test_sort_simple_sections(self, nis2_generator):
+ """Test sorting simple section numbers."""
+ result = nis2_generator._sort_section_key("1")
+ assert result == (1,)
+
+ result = nis2_generator._sort_section_key("2")
+ assert result == (2,)
+
+ def test_sort_subsections(self, nis2_generator):
+ """Test sorting subsection numbers."""
+ result = nis2_generator._sort_section_key("1.1")
+ assert result == (1, 1)
+
+ result = nis2_generator._sort_section_key("1.2")
+ assert result == (1, 2)
+
+ def test_sort_double_digit_sections(self, nis2_generator):
+ """Test sorting double digit section numbers."""
+ result = nis2_generator._sort_section_key("11")
+ assert result == (11,)
+
+ result = nis2_generator._sort_section_key("11.2")
+ assert result == (11, 2)
+
+ def test_sort_order_is_correct(self, nis2_generator):
+ """Test that sort order is numerically correct."""
+ keys = ["11", "1", "2", "1.2", "1.1", "11.2", "2.1"]
+ sorted_keys = sorted(keys, key=nis2_generator._sort_section_key)
+
+ assert sorted_keys == ["1", "1.1", "1.2", "2", "2.1", "11", "11.2"]
+
+ def test_sort_invalid_key(self, nis2_generator):
+ """Test sorting invalid section key."""
+ result = nis2_generator._sort_section_key("Other")
+ # Should contain infinity for non-numeric parts
+ assert result[0] == float("inf")
+
+
+# =============================================================================
+# Empty Data Tests
+# =============================================================================
+
+
+class TestNIS2EmptyData:
+ """Test suite for NIS2 with empty or minimal data."""
+
+ def test_executive_summary_empty_requirements(
+ self, nis2_generator, basic_nis2_compliance_data
+ ):
+ """Test executive summary with no requirements."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data)
+
+ assert len(elements) > 0
+
+ def test_charts_section_empty_requirements(
+ self, nis2_generator, basic_nis2_compliance_data
+ ):
+ """Test charts section with no requirements."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_charts_section(basic_nis2_compliance_data)
+
+ assert len(elements) > 0
+
+ def test_requirements_index_empty(self, nis2_generator, basic_nis2_compliance_data):
+ """Test requirements index with no requirements."""
+ basic_nis2_compliance_data.requirements = []
+ basic_nis2_compliance_data.attributes_by_requirement_id = {}
+
+ elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data)
+
+ # Should at least have the title
+ assert len(elements) >= 1
+
+
+# =============================================================================
+# All Pass / All Fail Tests
+# =============================================================================
+
+
+class TestNIS2EdgeCases:
+ """Test suite for NIS2 edge cases."""
+
+ def test_all_requirements_pass(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test with all requirements passing."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id=f"REQ-{i:03d}",
+ description=f"Passing requirement {i}",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ )
+ for i in range(1, 6)
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ f"REQ-{i:03d}": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ }
+ for i in range(1, 6)
+ }
+
+ elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data)
+ assert len(elements) > 0
+
+ def test_all_requirements_fail(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test with all requirements failing."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id=f"REQ-{i:03d}",
+ description=f"Failing requirement {i}",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ )
+ for i in range(1, 6)
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ f"REQ-{i:03d}": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ }
+ for i in range(1, 6)
+ }
+
+ elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data)
+ assert len(elements) > 0
+
+ def test_all_requirements_manual(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ ):
+ """Test with all requirements being manual."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id=f"REQ-{i:03d}",
+ description=f"Manual requirement {i}",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ )
+ for i in range(1, 6)
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ f"REQ-{i:03d}": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ }
+ for i in range(1, 6)
+ }
+
+ # Should handle gracefully - compliance should be 100% when no evaluated
+ elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data)
+ assert len(elements) > 0
+
+
+# =============================================================================
+# Integration Tests
+# =============================================================================
+
+
+class TestNIS2Integration:
+ """Integration tests for NIS2 report generation."""
+
+ def test_full_report_generation_flow(
+ self,
+ nis2_generator,
+ basic_nis2_compliance_data,
+ mock_nis2_requirement_attribute_section1,
+ mock_nis2_requirement_attribute_section2,
+ ):
+ """Test the complete report generation flow."""
+ basic_nis2_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Section 1 passed",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Section 2 failed",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=5,
+ total_findings=5,
+ ),
+ ]
+ basic_nis2_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section1]
+ }
+ },
+ "REQ-002": {
+ "attributes": {
+ "req_attributes": [mock_nis2_requirement_attribute_section2]
+ }
+ },
+ }
+
+ # Generate all sections
+ exec_summary = nis2_generator.create_executive_summary(
+ basic_nis2_compliance_data
+ )
+ charts = nis2_generator.create_charts_section(basic_nis2_compliance_data)
+ index = nis2_generator.create_requirements_index(basic_nis2_compliance_data)
+
+ # All sections should generate without errors
+ assert len(exec_summary) > 0
+ assert len(charts) > 0
+ assert len(index) > 0
diff --git a/api/src/backend/tasks/tests/test_reports_threatscore.py b/api/src/backend/tasks/tests/test_reports_threatscore.py
new file mode 100644
index 0000000000..c79c0b16e9
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_reports_threatscore.py
@@ -0,0 +1,1093 @@
+import io
+from unittest.mock import Mock
+
+import pytest
+from reportlab.platypus import Image, PageBreak, Paragraph, Table
+from tasks.jobs.reports import (
+ FRAMEWORK_REGISTRY,
+ ComplianceData,
+ RequirementData,
+ ThreatScoreReportGenerator,
+)
+
+from api.models import StatusChoices
+
+# =============================================================================
+# Fixtures
+# =============================================================================
+
+
+@pytest.fixture
+def threatscore_generator():
+ """Create a ThreatScoreReportGenerator instance for testing."""
+ config = FRAMEWORK_REGISTRY["prowler_threatscore"]
+ return ThreatScoreReportGenerator(config)
+
+
+@pytest.fixture
+def mock_requirement_attribute():
+ """Create a mock requirement attribute with numeric values."""
+ mock = Mock()
+ mock.LevelOfRisk = 4
+ mock.Weight = 100
+ mock.Section = "1. IAM"
+ mock.SubSection = "1.1 Access Control"
+ mock.Title = "Test Requirement"
+ mock.AttributeDescription = "Test Description"
+ return mock
+
+
+@pytest.fixture
+def mock_requirement_attribute_string_values():
+ """Create a mock requirement attribute with string values (edge case)."""
+ mock = Mock()
+ mock.LevelOfRisk = "5" # String instead of int
+ mock.Weight = "150" # String instead of int
+ mock.Section = "2. Attack Surface"
+ mock.SubSection = "2.1 Exposure"
+ mock.Title = "String Values Requirement"
+ mock.AttributeDescription = "Test with string numeric values"
+ return mock
+
+
+@pytest.fixture
+def mock_requirement_attribute_invalid_values():
+ """Create a mock requirement attribute with invalid values (edge case)."""
+ mock = Mock()
+ mock.LevelOfRisk = "High" # Invalid string
+ mock.Weight = "Critical" # Invalid string
+ mock.Section = "3. Logging"
+ mock.SubSection = "3.1 Audit"
+ mock.Title = "Invalid Values Requirement"
+ mock.AttributeDescription = "Test with invalid string values"
+ return mock
+
+
+@pytest.fixture
+def mock_requirement_attribute_empty_values():
+ """Create a mock requirement attribute with empty values."""
+ mock = Mock()
+ mock.LevelOfRisk = ""
+ mock.Weight = ""
+ mock.Section = "4. Encryption"
+ mock.SubSection = "4.1 Data at Rest"
+ mock.Title = "Empty Values Requirement"
+ mock.AttributeDescription = "Test with empty values"
+ return mock
+
+
+@pytest.fixture
+def mock_requirement_attribute_none_values():
+ """Create a mock requirement attribute with None values."""
+ mock = Mock()
+ mock.LevelOfRisk = None
+ mock.Weight = None
+ mock.Section = "1. IAM"
+ mock.SubSection = "1.2 Policies"
+ mock.Title = "None Values Requirement"
+ mock.AttributeDescription = "Test with None values"
+ return mock
+
+
+@pytest.fixture
+def basic_compliance_data():
+ """Create basic ComplianceData for testing."""
+ return ComplianceData(
+ tenant_id="tenant-123",
+ scan_id="scan-456",
+ provider_id="provider-789",
+ compliance_id="prowler_threatscore_aws",
+ framework="Prowler ThreatScore",
+ name="ThreatScore AWS",
+ version="1.0",
+ description="Security assessment framework",
+ )
+
+
+# =============================================================================
+# ThreatScore Calculation Tests
+# =============================================================================
+
+
+class TestThreatScoreCalculation:
+ """Test suite for ThreatScore calculation logic."""
+
+ def test_calculate_threatscore_no_findings_returns_100(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test that 100% is returned when there are no findings."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {}
+
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ assert result == 100.0
+
+ def test_calculate_threatscore_all_passed(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test ThreatScore calculation when all findings pass."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_requirement_attribute]},
+ }
+ }
+
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ assert result == 100.0
+
+ def test_calculate_threatscore_all_failed(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test ThreatScore calculation when all findings fail."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_requirement_attribute]},
+ }
+ }
+
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ assert result == 0.0
+
+ def test_calculate_threatscore_mixed_findings(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test ThreatScore calculation with mixed pass/fail findings."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=7,
+ failed_findings=3,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {"req_attributes": [mock_requirement_attribute]},
+ }
+ }
+
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ # rate_i = 7/10 = 0.7
+ # rfac_i = 1 + 0.25 * 4 = 2.0
+ # numerator = 0.7 * 10 * 100 * 2.0 = 1400
+ # denominator = 10 * 100 * 2.0 = 2000
+ # score = (1400 / 2000) * 100 = 70.0
+ assert result == 70.0
+
+ def test_calculate_threatscore_multiple_requirements(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test ThreatScore calculation with multiple requirements."""
+ mock_attr_1 = Mock()
+ mock_attr_1.LevelOfRisk = 5
+ mock_attr_1.Weight = 100
+
+ mock_attr_2 = Mock()
+ mock_attr_2.LevelOfRisk = 3
+ mock_attr_2.Weight = 50
+
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="High risk requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=8,
+ failed_findings=2,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Low risk requirement",
+ status=StatusChoices.PASS,
+ passed_findings=5,
+ failed_findings=0,
+ total_findings=5,
+ ),
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}},
+ "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}},
+ }
+
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ # REQ-001: rate=0.8, rfac=2.25, num=0.8*10*100*2.25=1800, den=10*100*2.25=2250
+ # REQ-002: rate=1.0, rfac=1.75, num=1.0*5*50*1.75=437.5, den=5*50*1.75=437.5
+ # total_num = 1800 + 437.5 = 2237.5
+ # total_den = 2250 + 437.5 = 2687.5
+ # score = (2237.5 / 2687.5) * 100 β 83.26%
+ assert 83.0 < result < 84.0
+
+ def test_calculate_threatscore_zero_weight(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test ThreatScore calculation with zero weight."""
+ mock_attr = Mock()
+ mock_attr.LevelOfRisk = 4
+ mock_attr.Weight = 0
+
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Zero weight requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=5,
+ failed_findings=5,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr]}},
+ }
+
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ # With weight=0, denominator will be 0, should return 0.0
+ assert result == 0.0
+
+
+# =============================================================================
+# Type Conversion Tests (Critical for bug fix validation)
+# =============================================================================
+
+
+class TestTypeConversionSafety:
+ """Test suite for type conversion safety in ThreatScore calculations."""
+
+ def test_calculate_threatscore_with_string_risk_level(
+ self,
+ threatscore_generator,
+ basic_compliance_data,
+ mock_requirement_attribute_string_values,
+ ):
+ """Test that string LevelOfRisk is correctly converted to int."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="String values test",
+ status=StatusChoices.FAIL,
+ passed_findings=5,
+ failed_findings=5,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_requirement_attribute_string_values]
+ }
+ },
+ }
+
+ # Should not raise TypeError: '<=' not supported between 'str' and 'int'
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ # LevelOfRisk="5" -> 5, Weight="150" -> 150
+ # rate_i = 0.5, rfac_i = 1 + 0.25*5 = 2.25
+ # numerator = 0.5 * 10 * 150 * 2.25 = 1687.5
+ # denominator = 10 * 150 * 2.25 = 3375
+ # score = 50.0
+ assert result == 50.0
+
+ def test_calculate_threatscore_with_invalid_string_values(
+ self,
+ threatscore_generator,
+ basic_compliance_data,
+ mock_requirement_attribute_invalid_values,
+ ):
+ """Test that invalid string values default to 0."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Invalid values test",
+ status=StatusChoices.FAIL,
+ passed_findings=5,
+ failed_findings=5,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_requirement_attribute_invalid_values]
+ }
+ },
+ }
+
+ # Should not raise ValueError, should default to 0
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ # With weight=0 (from invalid string), denominator is 0
+ assert result == 0.0
+
+ def test_calculate_threatscore_with_empty_values(
+ self,
+ threatscore_generator,
+ basic_compliance_data,
+ mock_requirement_attribute_empty_values,
+ ):
+ """Test that empty string values default to 0."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Empty values test",
+ status=StatusChoices.FAIL,
+ passed_findings=5,
+ failed_findings=5,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_requirement_attribute_empty_values]
+ }
+ },
+ }
+
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ # Empty strings should default to 0
+ assert result == 0.0
+
+ def test_calculate_threatscore_with_none_values(
+ self,
+ threatscore_generator,
+ basic_compliance_data,
+ mock_requirement_attribute_none_values,
+ ):
+ """Test that None values default to 0."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="None values test",
+ status=StatusChoices.FAIL,
+ passed_findings=5,
+ failed_findings=5,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_requirement_attribute_none_values]
+ }
+ },
+ }
+
+ result = threatscore_generator._calculate_threatscore(basic_compliance_data)
+
+ # None values should default to 0
+ assert result == 0.0
+
+ def test_critical_failed_requirements_with_string_risk_level(
+ self,
+ threatscore_generator,
+ basic_compliance_data,
+ mock_requirement_attribute_string_values,
+ ):
+ """Test that critical requirements filter works with string LevelOfRisk."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="High risk with string",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_requirement_attribute_string_values]
+ }
+ },
+ }
+
+ # Should not raise TypeError
+ result = threatscore_generator._get_critical_failed_requirements(
+ basic_compliance_data, min_risk_level=4
+ )
+
+ # LevelOfRisk="5" should be converted to 5, which is >= 4
+ assert len(result) == 1
+ assert result[0]["id"] == "REQ-001"
+ assert result[0]["risk_level"] == 5
+ assert result[0]["weight"] == 150
+
+ def test_critical_failed_requirements_with_invalid_risk_level(
+ self,
+ threatscore_generator,
+ basic_compliance_data,
+ mock_requirement_attribute_invalid_values,
+ ):
+ """Test that invalid LevelOfRisk is excluded from critical requirements."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Invalid risk level",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {
+ "attributes": {
+ "req_attributes": [mock_requirement_attribute_invalid_values]
+ }
+ },
+ }
+
+ result = threatscore_generator._get_critical_failed_requirements(
+ basic_compliance_data, min_risk_level=4
+ )
+
+ # Invalid string defaults to 0, which is < 4
+ assert len(result) == 0
+
+
+# =============================================================================
+# Critical Failed Requirements Tests
+# =============================================================================
+
+
+class TestCriticalFailedRequirements:
+ """Test suite for critical failed requirements identification."""
+
+ def test_get_critical_failed_no_failures(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test that no critical requirements are returned when all pass."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passing requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}},
+ }
+
+ result = threatscore_generator._get_critical_failed_requirements(
+ basic_compliance_data, min_risk_level=4
+ )
+
+ assert len(result) == 0
+
+ def test_get_critical_failed_below_threshold(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test that low risk failures are not included."""
+ mock_attr = Mock()
+ mock_attr.LevelOfRisk = 2 # Below threshold of 4
+ mock_attr.Weight = 100
+ mock_attr.Title = "Low Risk"
+ mock_attr.Section = "1. IAM"
+
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Low risk failure",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr]}},
+ }
+
+ result = threatscore_generator._get_critical_failed_requirements(
+ basic_compliance_data, min_risk_level=4
+ )
+
+ assert len(result) == 0
+
+ def test_get_critical_failed_at_threshold(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test that requirements at exactly the threshold are included."""
+ mock_attr = Mock()
+ mock_attr.LevelOfRisk = 4 # Exactly at threshold
+ mock_attr.Weight = 100
+ mock_attr.Title = "At Threshold"
+ mock_attr.Section = "1. IAM"
+
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="At threshold failure",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr]}},
+ }
+
+ result = threatscore_generator._get_critical_failed_requirements(
+ basic_compliance_data, min_risk_level=4
+ )
+
+ assert len(result) == 1
+ assert result[0]["risk_level"] == 4
+
+ def test_get_critical_failed_sorted_by_risk_and_weight(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test that critical requirements are sorted by risk level then weight."""
+ mock_attr_1 = Mock()
+ mock_attr_1.LevelOfRisk = 4
+ mock_attr_1.Weight = 150
+ mock_attr_1.Title = "Mid risk, high weight"
+ mock_attr_1.Section = "1. IAM"
+
+ mock_attr_2 = Mock()
+ mock_attr_2.LevelOfRisk = 5
+ mock_attr_2.Weight = 50
+ mock_attr_2.Title = "High risk, low weight"
+ mock_attr_2.Section = "2. Attack Surface"
+
+ mock_attr_3 = Mock()
+ mock_attr_3.LevelOfRisk = 5
+ mock_attr_3.Weight = 100
+ mock_attr_3.Title = "High risk, mid weight"
+ mock_attr_3.Section = "3. Logging"
+
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="First",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=5,
+ total_findings=5,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Second",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=5,
+ total_findings=5,
+ ),
+ RequirementData(
+ id="REQ-003",
+ description="Third",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=5,
+ total_findings=5,
+ ),
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}},
+ "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}},
+ "REQ-003": {"attributes": {"req_attributes": [mock_attr_3]}},
+ }
+
+ result = threatscore_generator._get_critical_failed_requirements(
+ basic_compliance_data, min_risk_level=4
+ )
+
+ assert len(result) == 3
+ # Sorted by (risk_level, weight) descending
+ # First: risk=5, weight=100 (REQ-003)
+ # Second: risk=5, weight=50 (REQ-002)
+ # Third: risk=4, weight=150 (REQ-001)
+ assert result[0]["id"] == "REQ-003"
+ assert result[1]["id"] == "REQ-002"
+ assert result[2]["id"] == "REQ-001"
+
+ def test_get_critical_failed_manual_status_excluded(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test that MANUAL status requirements are excluded."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Manual requirement",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}},
+ }
+
+ result = threatscore_generator._get_critical_failed_requirements(
+ basic_compliance_data, min_risk_level=4
+ )
+
+ assert len(result) == 0
+
+
+# =============================================================================
+# Section Score Chart Tests
+# =============================================================================
+
+
+class TestSectionScoreChart:
+ """Test suite for section score chart generation."""
+
+ def test_create_section_chart_empty_data(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test chart creation with no requirements."""
+ basic_compliance_data.requirements = []
+ basic_compliance_data.attributes_by_requirement_id = {}
+
+ result = threatscore_generator._create_section_score_chart(
+ basic_compliance_data
+ )
+
+ assert isinstance(result, io.BytesIO)
+ assert result.getvalue() # Should have content
+
+ def test_create_section_chart_single_section(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test chart creation with a single section."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="IAM requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}},
+ }
+
+ result = threatscore_generator._create_section_score_chart(
+ basic_compliance_data
+ )
+
+ assert isinstance(result, io.BytesIO)
+
+ def test_create_section_chart_multiple_sections(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test chart creation with multiple sections."""
+ mock_attr_1 = Mock()
+ mock_attr_1.LevelOfRisk = 4
+ mock_attr_1.Weight = 100
+ mock_attr_1.Section = "1. IAM"
+
+ mock_attr_2 = Mock()
+ mock_attr_2.LevelOfRisk = 3
+ mock_attr_2.Weight = 50
+ mock_attr_2.Section = "2. Attack Surface"
+
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="IAM requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ ),
+ RequirementData(
+ id="REQ-002",
+ description="Attack Surface requirement",
+ status=StatusChoices.FAIL,
+ passed_findings=5,
+ failed_findings=5,
+ total_findings=10,
+ ),
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}},
+ "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}},
+ }
+
+ result = threatscore_generator._create_section_score_chart(
+ basic_compliance_data
+ )
+
+ assert isinstance(result, io.BytesIO)
+
+ def test_create_section_chart_no_findings_section_gets_100(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test that sections without findings get 100% score."""
+ mock_attr = Mock()
+ mock_attr.LevelOfRisk = 4
+ mock_attr.Weight = 100
+ mock_attr.Section = "1. IAM"
+
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="No findings requirement",
+ status=StatusChoices.MANUAL,
+ passed_findings=0,
+ failed_findings=0,
+ total_findings=0,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr]}},
+ }
+
+ # Chart should be created without errors
+ result = threatscore_generator._create_section_score_chart(
+ basic_compliance_data
+ )
+
+ assert isinstance(result, io.BytesIO)
+
+
+# =============================================================================
+# Executive Summary Tests
+# =============================================================================
+
+
+class TestExecutiveSummary:
+ """Test suite for executive summary generation."""
+
+ def test_executive_summary_contains_chart(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test that executive summary contains a chart."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}},
+ }
+
+ elements = threatscore_generator.create_executive_summary(basic_compliance_data)
+
+ assert len(elements) > 0
+ assert any(isinstance(e, Image) for e in elements)
+
+ def test_executive_summary_contains_score_table(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test that executive summary contains a score table."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}},
+ }
+
+ elements = threatscore_generator.create_executive_summary(basic_compliance_data)
+
+ assert any(isinstance(e, Table) for e in elements)
+
+
+# =============================================================================
+# Charts Section Tests
+# =============================================================================
+
+
+class TestChartsSection:
+ """Test suite for charts section generation."""
+
+ def test_charts_section_no_critical_failures(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test charts section when no critical failures exist."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Passing requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}},
+ }
+
+ elements = threatscore_generator.create_charts_section(basic_compliance_data)
+
+ assert len(elements) > 0
+ # Should contain success message
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "No critical failed requirements" in content or "Great job" in content
+
+ def test_charts_section_with_critical_failures(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test charts section when critical failures exist."""
+ mock_attr = Mock()
+ mock_attr.LevelOfRisk = 5
+ mock_attr.Weight = 100
+ mock_attr.Title = "Critical Failure"
+ mock_attr.Section = "1. IAM"
+
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Critical failure",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr]}},
+ }
+
+ elements = threatscore_generator.create_charts_section(basic_compliance_data)
+
+ assert len(elements) > 0
+ # Should contain a table with critical requirements
+ assert any(isinstance(e, Table) for e in elements)
+
+ def test_charts_section_starts_with_page_break(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test that charts section starts with a page break."""
+ basic_compliance_data.requirements = []
+ basic_compliance_data.attributes_by_requirement_id = {}
+
+ elements = threatscore_generator.create_charts_section(basic_compliance_data)
+
+ assert len(elements) > 0
+ assert isinstance(elements[0], PageBreak)
+
+ def test_charts_section_respects_min_risk_level(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test that charts section respects the min_risk_level setting."""
+ threatscore_generator._min_risk_level = 5 # Higher threshold
+
+ mock_attr = Mock()
+ mock_attr.LevelOfRisk = 4 # Below the new threshold
+ mock_attr.Weight = 100
+ mock_attr.Title = "Medium Risk"
+ mock_attr.Section = "1. IAM"
+
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Medium risk failure",
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr]}},
+ }
+
+ elements = threatscore_generator.create_charts_section(basic_compliance_data)
+
+ # Should not contain a table since risk=4 < min=5
+ tables = [e for e in elements if isinstance(e, Table)]
+ assert len(tables) == 0
+
+
+# =============================================================================
+# Requirements Index Tests
+# =============================================================================
+
+
+class TestRequirementsIndex:
+ """Test suite for requirements index generation."""
+
+ def test_requirements_index_empty(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test requirements index with no requirements."""
+ basic_compliance_data.requirements = []
+ basic_compliance_data.attributes_by_requirement_id = {}
+
+ elements = threatscore_generator.create_requirements_index(
+ basic_compliance_data
+ )
+
+ assert len(elements) >= 1 # At least the header
+ assert isinstance(elements[0], Paragraph)
+
+ def test_requirements_index_single_requirement(
+ self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ ):
+ """Test requirements index with a single requirement."""
+ basic_compliance_data.requirements = [
+ RequirementData(
+ id="REQ-001",
+ description="Test requirement",
+ status=StatusChoices.PASS,
+ passed_findings=10,
+ failed_findings=0,
+ total_findings=10,
+ )
+ ]
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}},
+ }
+
+ elements = threatscore_generator.create_requirements_index(
+ basic_compliance_data
+ )
+
+ assert len(elements) >= 2 # Header + at least section header
+
+ def test_requirements_index_organized_by_section(
+ self, threatscore_generator, basic_compliance_data
+ ):
+ """Test that requirements index is organized by section."""
+ mock_attr_1 = Mock()
+ mock_attr_1.Section = "1. IAM"
+ mock_attr_1.SubSection = "1.1 Access"
+ mock_attr_1.Title = "IAM Requirement"
+
+ mock_attr_2 = Mock()
+ mock_attr_2.Section = "2. Attack Surface"
+ mock_attr_2.SubSection = "2.1 Exposure"
+ mock_attr_2.Title = "Attack Surface Requirement"
+
+ basic_compliance_data.requirements = []
+ basic_compliance_data.attributes_by_requirement_id = {
+ "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}},
+ "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}},
+ }
+
+ elements = threatscore_generator.create_requirements_index(
+ basic_compliance_data
+ )
+
+ # Check that section headers are present
+ paragraphs = [e for e in elements if isinstance(e, Paragraph)]
+ content = " ".join(str(p.text) for p in paragraphs)
+ assert "IAM" in content or "1." in content
+
+
+# =============================================================================
+# Critical Requirements Table Tests
+# =============================================================================
+
+
+class TestCriticalRequirementsTable:
+ """Test suite for critical requirements table generation."""
+
+ def test_create_table_single_requirement(self, threatscore_generator):
+ """Test table creation with a single requirement."""
+ critical = [
+ {
+ "id": "REQ-001",
+ "risk_level": 5,
+ "weight": 100,
+ "title": "Test Requirement",
+ "section": "1. IAM",
+ }
+ ]
+
+ table = threatscore_generator._create_critical_requirements_table(critical)
+
+ assert isinstance(table, Table)
+
+ def test_create_table_truncates_long_titles(self, threatscore_generator):
+ """Test that long titles are truncated."""
+ critical = [
+ {
+ "id": "REQ-001",
+ "risk_level": 5,
+ "weight": 100,
+ "title": "A" * 100, # Very long title
+ "section": "1. IAM",
+ }
+ ]
+
+ table = threatscore_generator._create_critical_requirements_table(critical)
+
+ # Table should be created without errors
+ assert isinstance(table, Table)
+
+ def test_create_table_multiple_requirements(self, threatscore_generator):
+ """Test table creation with multiple requirements."""
+ critical = [
+ {
+ "id": "REQ-001",
+ "risk_level": 5,
+ "weight": 150,
+ "title": "First",
+ "section": "1. IAM",
+ },
+ {
+ "id": "REQ-002",
+ "risk_level": 4,
+ "weight": 100,
+ "title": "Second",
+ "section": "2. Attack Surface",
+ },
+ ]
+
+ table = threatscore_generator._create_critical_requirements_table(critical)
+
+ assert isinstance(table, Table)
diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py
index 8902b17b54..5f244e0103 100644
--- a/api/src/backend/tasks/tests/test_scan.py
+++ b/api/src/backend/tasks/tests/test_scan.py
@@ -1380,6 +1380,8 @@ class TestProcessFindingMicroBatch:
scan_resource_cache: set[tuple[str, str, str, str]] = set()
mute_rules_cache = {}
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
+ scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
+ group_resources_cache: dict[str, set] = {}
with (
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
@@ -1398,6 +1400,8 @@ class TestProcessFindingMicroBatch:
scan_resource_cache,
mute_rules_cache,
scan_categories_cache,
+ scan_resource_groups_cache,
+ group_resources_cache,
)
created_finding = Finding.objects.get(uid=finding.uid)
@@ -1491,6 +1495,8 @@ class TestProcessFindingMicroBatch:
scan_resource_cache: set[tuple[str, str, str, str]] = set()
mute_rules_cache = {finding.uid: "Muted via rule"}
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
+ scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
+ group_resources_cache: dict[str, set] = {}
with (
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
@@ -1509,6 +1515,8 @@ class TestProcessFindingMicroBatch:
scan_resource_cache,
mute_rules_cache,
scan_categories_cache,
+ scan_resource_groups_cache,
+ group_resources_cache,
)
existing_resource.refresh_from_db()
@@ -1617,6 +1625,8 @@ class TestProcessFindingMicroBatch:
scan_resource_cache: set[tuple[str, str, str, str]] = set()
mute_rules_cache = {}
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
+ scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
+ group_resources_cache: dict[str, set] = {}
with (
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
@@ -1636,6 +1646,8 @@ class TestProcessFindingMicroBatch:
scan_resource_cache,
mute_rules_cache,
scan_categories_cache,
+ scan_resource_groups_cache,
+ group_resources_cache,
)
# Verify the long UID finding was NOT created
@@ -1713,6 +1725,8 @@ class TestProcessFindingMicroBatch:
scan_resource_cache: set[tuple[str, str, str, str]] = set()
mute_rules_cache = {}
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
+ scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
+ group_resources_cache: dict[str, set] = {}
with (
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
@@ -1731,6 +1745,8 @@ class TestProcessFindingMicroBatch:
scan_resource_cache,
mute_rules_cache,
scan_categories_cache,
+ scan_resource_groups_cache,
+ group_resources_cache,
)
# finding1: PASS, severity=low, categories=["gen-ai", "security"]
diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py
index 376315d90d..3a58118c62 100644
--- a/api/src/backend/tasks/tests/test_tasks.py
+++ b/api/src/backend/tasks/tests/test_tasks.py
@@ -1,10 +1,13 @@
import uuid
+from contextlib import contextmanager
+from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import openai
import pytest
from botocore.exceptions import ClientError
from django_celery_beat.models import IntervalSchedule, PeriodicTask
+from django_celery_results.models import TaskResult
from tasks.jobs.lighthouse_providers import (
_create_bedrock_client,
_extract_bedrock_credentials,
@@ -15,6 +18,8 @@ from tasks.tasks import (
check_integrations_task,
check_lighthouse_provider_connection_task,
generate_outputs_task,
+ perform_attack_paths_scan_task,
+ perform_scheduled_scan_task,
refresh_lighthouse_provider_models_task,
s3_integration_task,
security_hub_integration_task,
@@ -26,6 +31,7 @@ from api.models import (
LighthouseProviderModels,
Scan,
StateChoices,
+ Task,
)
@@ -737,8 +743,12 @@ class TestScanCompleteTasks:
@patch("tasks.tasks.generate_outputs_task.si")
@patch("tasks.tasks.generate_compliance_reports_task.si")
@patch("tasks.tasks.check_integrations_task.si")
+ @patch("tasks.tasks.perform_attack_paths_scan_task.apply_async")
+ @patch("tasks.tasks.can_provider_run_attack_paths_scan", return_value=False)
def test_scan_complete_tasks(
self,
+ mock_can_run_attack_paths,
+ mock_attack_paths_task,
mock_check_integrations_task,
mock_compliance_reports_task,
mock_outputs_task,
@@ -793,6 +803,67 @@ class TestScanCompleteTasks:
scan_id="scan-id",
)
+ # Attack Paths task should be skipped when provider cannot run it
+ mock_attack_paths_task.assert_not_called()
+
+
+class TestAttackPathsTasks:
+ @staticmethod
+ @contextmanager
+ def _override_task_request(task, **attrs):
+ request = task.request
+ sentinel = object()
+ previous = {key: getattr(request, key, sentinel) for key in attrs}
+ for key, value in attrs.items():
+ setattr(request, key, value)
+
+ try:
+ yield
+ finally:
+ for key, prev in previous.items():
+ if prev is sentinel:
+ if hasattr(request, key):
+ delattr(request, key)
+ else:
+ setattr(request, key, prev)
+
+ def test_perform_attack_paths_scan_task_calls_runner(self):
+ with (
+ patch("tasks.tasks.attack_paths_scan") as mock_attack_paths_scan,
+ self._override_task_request(
+ perform_attack_paths_scan_task, id="celery-task-id"
+ ),
+ ):
+ mock_attack_paths_scan.return_value = {"status": "ok"}
+
+ result = perform_attack_paths_scan_task.run(
+ tenant_id="tenant-id", scan_id="scan-id"
+ )
+
+ mock_attack_paths_scan.assert_called_once_with(
+ tenant_id="tenant-id", scan_id="scan-id", task_id="celery-task-id"
+ )
+ assert result == {"status": "ok"}
+
+ def test_perform_attack_paths_scan_task_propagates_exception(self):
+ with (
+ patch(
+ "tasks.tasks.attack_paths_scan",
+ side_effect=RuntimeError("Exception to propagate"),
+ ) as mock_attack_paths_scan,
+ self._override_task_request(
+ perform_attack_paths_scan_task, id="celery-task-error"
+ ),
+ ):
+ with pytest.raises(RuntimeError, match="Exception to propagate"):
+ perform_attack_paths_scan_task.run(
+ tenant_id="tenant-id", scan_id="scan-id"
+ )
+
+ mock_attack_paths_scan.assert_called_once_with(
+ tenant_id="tenant-id", scan_id="scan-id", task_id="celery-task-error"
+ )
+
@pytest.mark.django_db
class TestCheckIntegrationsTask:
@@ -2068,3 +2139,215 @@ class TestCleanupOrphanScheduledScans:
assert not Scan.objects.filter(id=orphan_scan.id).exists()
assert Scan.objects.filter(id=scheduled_scan.id).exists()
assert Scan.objects.filter(id=available_scan_other_task.id).exists()
+
+
+@pytest.mark.django_db
+class TestPerformScheduledScanTask:
+ """Unit tests for perform_scheduled_scan_task."""
+
+ @staticmethod
+ @contextmanager
+ def _override_task_request(task, **attrs):
+ request = task.request
+ sentinel = object()
+ previous = {key: getattr(request, key, sentinel) for key in attrs}
+ for key, value in attrs.items():
+ setattr(request, key, value)
+
+ try:
+ yield
+ finally:
+ for key, prev in previous.items():
+ if prev is sentinel:
+ if hasattr(request, key):
+ delattr(request, key)
+ else:
+ setattr(request, key, prev)
+
+ def _create_periodic_task(self, provider_id, tenant_id, interval_hours=24):
+ interval, _ = IntervalSchedule.objects.get_or_create(
+ every=interval_hours, period="hours"
+ )
+ return PeriodicTask.objects.create(
+ name=f"scan-perform-scheduled-{provider_id}",
+ task="scan-perform-scheduled",
+ interval=interval,
+ kwargs=f'{{"tenant_id": "{tenant_id}", "provider_id": "{provider_id}"}}',
+ enabled=True,
+ )
+
+ def _create_task_result(self, tenant_id, task_id):
+ task_result = TaskResult.objects.create(
+ task_id=task_id,
+ task_name="scan-perform-scheduled",
+ status="STARTED",
+ date_created=datetime.now(timezone.utc),
+ )
+ Task.objects.create(
+ id=task_id, task_runner_task=task_result, tenant_id=tenant_id
+ )
+ return task_result
+
+ def test_skip_when_scheduled_scan_executing(
+ self, tenants_fixture, providers_fixture
+ ):
+ """Skip a scheduled run when another scheduled scan is already executing."""
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+ periodic_task = self._create_periodic_task(provider.id, tenant.id)
+ task_id = str(uuid.uuid4())
+ self._create_task_result(tenant.id, task_id)
+
+ executing_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Daily scheduled scan",
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.EXECUTING,
+ scheduler_task_id=periodic_task.id,
+ )
+
+ with (
+ patch("tasks.tasks.perform_prowler_scan") as mock_scan,
+ patch("tasks.tasks._perform_scan_complete_tasks") as mock_complete_tasks,
+ self._override_task_request(perform_scheduled_scan_task, id=task_id),
+ ):
+ result = perform_scheduled_scan_task.run(
+ tenant_id=str(tenant.id), provider_id=str(provider.id)
+ )
+
+ mock_scan.assert_not_called()
+ mock_complete_tasks.assert_not_called()
+ assert result["id"] == str(executing_scan.id)
+ assert result["state"] == StateChoices.EXECUTING
+ assert (
+ Scan.objects.filter(
+ tenant_id=tenant.id,
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.SCHEDULED,
+ ).count()
+ == 0
+ )
+
+ def test_creates_next_scheduled_scan_after_completion(
+ self, tenants_fixture, providers_fixture
+ ):
+ """Create a next scheduled scan after a successful run completes."""
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+ self._create_periodic_task(provider.id, tenant.id)
+ task_id = str(uuid.uuid4())
+ self._create_task_result(tenant.id, task_id)
+
+ def _complete_scan(tenant_id, scan_id, provider_id):
+ other_scheduled = Scan.objects.filter(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.SCHEDULED,
+ ).exclude(id=scan_id)
+ assert not other_scheduled.exists()
+ scan_instance = Scan.objects.get(id=scan_id)
+ scan_instance.state = StateChoices.COMPLETED
+ scan_instance.save()
+ return {"status": "ok"}
+
+ with (
+ patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
+ patch("tasks.tasks._perform_scan_complete_tasks"),
+ self._override_task_request(perform_scheduled_scan_task, id=task_id),
+ ):
+ perform_scheduled_scan_task.run(
+ tenant_id=str(tenant.id), provider_id=str(provider.id)
+ )
+
+ scheduled_scans = Scan.objects.filter(
+ tenant_id=tenant.id,
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.SCHEDULED,
+ )
+ assert scheduled_scans.count() == 1
+ assert scheduled_scans.first().scheduled_at > datetime.now(timezone.utc)
+ assert (
+ Scan.objects.filter(
+ tenant_id=tenant.id,
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE),
+ ).count()
+ == 1
+ )
+ assert (
+ Scan.objects.filter(
+ tenant_id=tenant.id,
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.COMPLETED,
+ ).count()
+ == 1
+ )
+
+ def test_dedupes_multiple_scheduled_scans_before_run(
+ self, tenants_fixture, providers_fixture
+ ):
+ """Ensure duplicated scheduled scans are removed before executing."""
+ tenant = tenants_fixture[0]
+ provider = providers_fixture[0]
+ periodic_task = self._create_periodic_task(provider.id, tenant.id)
+ task_id = str(uuid.uuid4())
+ self._create_task_result(tenant.id, task_id)
+
+ scheduled_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Daily scheduled scan",
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.SCHEDULED,
+ scheduled_at=datetime.now(timezone.utc),
+ scheduler_task_id=periodic_task.id,
+ )
+ duplicate_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Daily scheduled scan",
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.AVAILABLE,
+ scheduled_at=scheduled_scan.scheduled_at,
+ scheduler_task_id=periodic_task.id,
+ )
+
+ def _complete_scan(tenant_id, scan_id, provider_id):
+ other_scheduled = Scan.objects.filter(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE),
+ ).exclude(id=scan_id)
+ assert not other_scheduled.exists()
+ scan_instance = Scan.objects.get(id=scan_id)
+ scan_instance.state = StateChoices.COMPLETED
+ scan_instance.save()
+ return {"status": "ok"}
+
+ with (
+ patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
+ patch("tasks.tasks._perform_scan_complete_tasks"),
+ self._override_task_request(perform_scheduled_scan_task, id=task_id),
+ ):
+ perform_scheduled_scan_task.run(
+ tenant_id=str(tenant.id), provider_id=str(provider.id)
+ )
+
+ assert not Scan.objects.filter(id=duplicate_scan.id).exists()
+ assert Scan.objects.filter(id=scheduled_scan.id).exists()
+ assert (
+ Scan.objects.filter(
+ tenant_id=tenant.id,
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE),
+ ).count()
+ == 1
+ )
diff --git a/api/src/backend/tasks/utils.py b/api/src/backend/tasks/utils.py
index 21e30c9e29..eded5bfb9a 100644
--- a/api/src/backend/tasks/utils.py
+++ b/api/src/backend/tasks/utils.py
@@ -5,6 +5,10 @@ from enum import Enum
from django_celery_beat.models import PeriodicTask
from django_celery_results.models import TaskResult
+from api.models import Scan, StateChoices
+
+SCHEDULED_SCAN_NAME = "Daily scheduled scan"
+
class CustomEncoder(json.JSONEncoder):
def default(self, o):
@@ -71,3 +75,58 @@ def batched(iterable, batch_size):
batch = []
yield batch, True
+
+
+def _get_or_create_scheduled_scan(
+ tenant_id: str,
+ provider_id: str,
+ scheduler_task_id: int,
+ scheduled_at: datetime,
+ update_state: bool = False,
+) -> Scan:
+ """
+ Get or create a scheduled scan, cleaning up duplicates if found.
+
+ Args:
+ tenant_id: The tenant ID.
+ provider_id: The provider ID.
+ scheduler_task_id: The PeriodicTask ID.
+ scheduled_at: The scheduled datetime for the scan.
+ update_state: If True, also reset state to SCHEDULED when updating.
+
+ Returns:
+ The scan instance to use.
+ """
+ scheduled_scans = list(
+ Scan.objects.filter(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE),
+ scheduler_task_id=scheduler_task_id,
+ ).order_by("scheduled_at", "inserted_at")
+ )
+
+ if scheduled_scans:
+ scan_instance = scheduled_scans[0]
+ if len(scheduled_scans) > 1:
+ Scan.objects.filter(id__in=[s.id for s in scheduled_scans[1:]]).delete()
+ needs_update = scan_instance.scheduled_at != scheduled_at
+ if update_state and scan_instance.state != StateChoices.SCHEDULED:
+ scan_instance.state = StateChoices.SCHEDULED
+ scan_instance.name = SCHEDULED_SCAN_NAME
+ needs_update = True
+ if needs_update:
+ scan_instance.scheduled_at = scheduled_at
+ scan_instance.save()
+ return scan_instance
+
+ return Scan.objects.create(
+ tenant_id=tenant_id,
+ name=SCHEDULED_SCAN_NAME,
+ provider_id=provider_id,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.SCHEDULED,
+ scheduled_at=scheduled_at,
+ scheduler_task_id=scheduler_task_id,
+ )
diff --git a/prowler/providers/cloudflare/services/zones/__init__.py b/contrib/aws/simulate_policy/__init__.py
similarity index 100%
rename from prowler/providers/cloudflare/services/zones/__init__.py
rename to contrib/aws/simulate_policy/__init__.py
diff --git a/contrib/aws/simulate_policy/simulate_policy_client.py b/contrib/aws/simulate_policy/simulate_policy_client.py
new file mode 100644
index 0000000000..eebc9d5174
--- /dev/null
+++ b/contrib/aws/simulate_policy/simulate_policy_client.py
@@ -0,0 +1,20 @@
+# prowler/contrib/aws/simulate_policy_client.py
+from typing import Optional
+
+from prowler.contrib.aws.simulate_policy.simulate_policy_service import IamSimulator
+from prowler.providers.common.provider import Provider
+
+_iam_simulator_client: Optional[IamSimulator] = None
+
+
+def get_iam_simulator_client() -> IamSimulator:
+ global _iam_simulator_client
+ if _iam_simulator_client is None:
+ provider = Provider.get_global_provider()
+ if provider is None:
+ # Fail fast with a clear message if somehow called too early
+ raise RuntimeError(
+ "Global Provider is not initialized yet for IAM simulator."
+ )
+ _iam_simulator_client = IamSimulator(provider)
+ return _iam_simulator_client
diff --git a/contrib/aws/simulate_policy/simulate_policy_service.py b/contrib/aws/simulate_policy/simulate_policy_service.py
new file mode 100644
index 0000000000..b111515bb1
--- /dev/null
+++ b/contrib/aws/simulate_policy/simulate_policy_service.py
@@ -0,0 +1,200 @@
+# prowler/contrib/aws/simulate_policy_service.py
+
+import json
+import logging
+from typing import Dict, List, Optional, Tuple
+
+from botocore.exceptions import ClientError
+
+from prowler.providers.common.provider import Provider
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
+
+
+# ======================================================================
+# PURPOSE
+# ----------------------------------------------------------------------
+# This module provides a precise way to test IAM actions programmatically.
+# It replicates the behaviour of the AWS CLI command:
+# aws iam simulate-principal-policy --policy-source-arn arn:aws:iam:::role/ --action-names
+#
+# Use this when you need to validate whether a specific IAM role allows or denies
+# certain actions against given resources.
+#
+# ======================================================================
+# CLI ANALOGUE
+# ----------------------------------------------------------------------
+# Example equivalent CLI command:
+# aws iam simulate-principal-policy \
+# --policy-source-arn arn:aws:iam::278419598935:role/your-role \
+# --action-names datazone:AcceptPredictions
+#
+# ======================================================================
+# DOCUMENTATION
+# ----------------------------------------------------------------------
+# AWS IAM Policy Simulator:
+# https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_testing-policies.html
+#
+# IAM Condition Keys:
+# https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html
+#
+# Related AWS SDK discussion:
+# https://github.com/aws/aws-sdk/issues/102
+#
+# ======================================================================
+# LIMITATIONS
+# ----------------------------------------------------------------------
+# - The IAM Policy Simulator does NOT evaluate Service Control Policies (SCPs)
+# that include conditions. This is a limitation of the API.
+# - In environments where SCPs contain conditions, use
+# `is_action_allowed_simulate_custom_policy` instead.
+# - In environments without SCP conditions, `is_action_allowed_simulate_principal_policy`
+# works as expected.
+#
+# ======================================================================
+# USAGE
+# ----------------------------------------------------------------------
+# In your custom check:
+#
+# from prowler.contrib.aws.simulate_policy.simulate_policy_client import get_iam_simulator_client
+#
+# iam_sim = get_iam_simulator_client()
+# policy_data = iam_sim.get_role_policy_data(role_name=role_name)
+# iam_sim.is_action_allowed_simulate_custom_policy(
+# policy_data=policy_data,
+# action_names=[action],
+# resource_arns=["*"]
+# )
+#
+#
+# ======================================================================
+
+
+class IamSimulator:
+ """
+ Helper for IAM Policy Simulator:
+ - simulate_principal_policy
+ - simulate_custom_policy
+ - collect role inline/managed policies
+ """
+
+ def __init__(self, provider: Provider) -> None:
+
+ boto3_session = provider.session.current_session
+
+ # IAM is a global service. Region is optional; we can use the provider's global region
+ # to stay consistent across partitions.
+ try:
+ region_name = provider.get_global_region()
+ except AttributeError:
+ # Fallback if provider lacks the helper (older trees)
+ region_name = boto3_session.region_name or "us-east-1"
+
+ self.iam = boto3_session.client("iam", region_name=region_name)
+
+ def is_action_allowed_simulate_principal_policy(
+ self,
+ principal_arn: str,
+ action_names: List[str],
+ resource_arns: Optional[List[str]] = None,
+ ) -> Tuple[bool, Dict]:
+ if resource_arns is None:
+ resource_arns = ["*"]
+ try:
+ resp = self.iam.simulate_principal_policy(
+ PolicySourceArn=principal_arn,
+ ActionNames=action_names,
+ ResourceArns=resource_arns,
+ )
+ allowed = any(
+ r.get("EvalDecision") == "allowed"
+ for r in resp.get("EvaluationResults", [])
+ )
+ return allowed, resp
+ except ClientError as e:
+ logger.error("simulate_principal_policy failed: %s", e, exc_info=True)
+ return False, {"error": str(e)}
+
+ def get_role_policy_data(self, role_name: str) -> Dict[str, List]:
+ inline_names: List[str] = []
+ inline_docs: List[Dict] = []
+ managed_names: List[str] = []
+ managed_docs: List[Dict] = []
+
+ # Inline policies
+ inline_resp = self.iam.list_role_policies(RoleName=role_name)
+ inline_names = inline_resp.get("PolicyNames", [])
+ for pname in inline_names:
+ pol_resp = self.iam.get_role_policy(RoleName=role_name, PolicyName=pname)
+ inline_docs.append(pol_resp["PolicyDocument"]) # dict
+
+ # Managed policies
+ managed_resp = self.iam.list_attached_role_policies(RoleName=role_name)
+ for attached in managed_resp.get("AttachedPolicies", []):
+ managed_names.append(attached["PolicyName"])
+ pol_meta = self.iam.get_policy(PolicyArn=attached["PolicyArn"])["Policy"]
+ pol_ver = self.iam.get_policy_version(
+ PolicyArn=attached["PolicyArn"], VersionId=pol_meta["DefaultVersionId"]
+ )
+ managed_docs.append(pol_ver["PolicyVersion"]["Document"]) # dict
+
+ return {
+ "inline_policy_names": inline_names,
+ "inline_policy_data": inline_docs,
+ "managed_policy_names": managed_names,
+ "managed_policy_data": managed_docs,
+ }
+
+ def is_action_allowed_simulate_custom_policy(
+ self,
+ policy_data: Dict[str, List],
+ action_names: List[str],
+ resource_arns: Optional[List[str]] = None,
+ ) -> Tuple[bool, Dict]:
+ names = policy_data.get("inline_policy_names", []) + policy_data.get(
+ "managed_policy_names", []
+ )
+ docs = policy_data.get("inline_policy_data", []) + policy_data.get(
+ "managed_policy_data", []
+ )
+
+ results: Dict[str, List] = {"policies": []}
+ any_allowed = False
+ if resource_arns is None:
+ resource_arns = ["*"]
+
+ for idx, doc in enumerate(docs):
+ name = names[idx] if idx < len(names) else f"policy_{idx}"
+ try:
+ sim_resp = self.iam.simulate_custom_policy(
+ PolicyInputList=[json.dumps(doc)],
+ ActionNames=action_names,
+ ResourceArns=resource_arns,
+ )
+ except ClientError as e:
+ logger.error(
+ "simulate_custom_policy failed for %s: %s", name, e, exc_info=True
+ )
+ results["policies"].append({"policy_name": name, "error": str(e)})
+ continue
+
+ per_action = []
+ for ev in sim_resp.get("EvaluationResults", []):
+ decision = ev.get(
+ "EvalDecision"
+ ) # allowed | explicitDeny | implicitDeny
+ per_action.append(
+ {
+ "action": ev.get("EvalActionName"),
+ "decision": decision,
+ "matching_statements": ev.get("MatchedStatements", []),
+ "missing_context_values": ev.get("MissingContextValues", []),
+ }
+ )
+ if decision == "allowed":
+ any_allowed = True
+
+ results["policies"].append({"policy_name": name, "evaluations": per_action})
+
+ return any_allowed, results
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 746948cc3a..b5de1af83f 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -1,6 +1,7 @@
services:
api-dev:
hostname: "prowler-api"
+ image: prowler-api-dev
build:
context: ./api
dockerfile: Dockerfile
@@ -24,6 +25,8 @@ services:
condition: service_healthy
valkey:
condition: service_healthy
+ neo4j:
+ condition: service_healthy
entrypoint:
- "/home/prowler/docker-entrypoint.sh"
- "dev"
@@ -85,7 +88,41 @@ services:
timeout: 5s
retries: 3
+ neo4j:
+ image: graphstack/dozerdb:5.26.3.0
+ hostname: "neo4j"
+ volumes:
+ - ./_data/neo4j:/data
+ environment:
+ # We can't add our .env file because some of our current variables are not compatible with Neo4j env vars
+ # Auth
+ - NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD}
+ # Memory limits
+ - NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000}
+ - NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE:-1G}
+ - NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G}
+ - NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G}
+ # APOC
+ - apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true}
+ - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true}
+ - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true}
+ - "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}"
+ - "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}"
+ - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}"
+ # Networking
+ - "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}"
+ # 7474 is the UI port
+ ports:
+ - 7474:7474
+ - ${NEO4J_PORT:-7687}:7687
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "http://localhost:7474"]
+ interval: 10s
+ timeout: 10s
+ retries: 10
+
worker-dev:
+ image: prowler-api-dev
build:
context: ./api
dockerfile: Dockerfile
@@ -96,17 +133,23 @@ services:
- path: .env
required: false
volumes:
- - "outputs:/tmp/prowler_api_output"
+ - ./api/src/backend:/home/prowler/backend
+ - ./api/pyproject.toml:/home/prowler/pyproject.toml
+ - ./api/docker-entrypoint.sh:/home/prowler/docker-entrypoint.sh
+ - outputs:/tmp/prowler_api_output
depends_on:
valkey:
condition: service_healthy
postgres:
condition: service_healthy
+ neo4j:
+ condition: service_healthy
entrypoint:
- "/home/prowler/docker-entrypoint.sh"
- "worker"
worker-beat:
+ image: prowler-api-dev
build:
context: ./api
dockerfile: Dockerfile
@@ -121,6 +164,8 @@ services:
condition: service_healthy
postgres:
condition: service_healthy
+ neo4j:
+ condition: service_healthy
entrypoint:
- "../docker-entrypoint.sh"
- "beat"
diff --git a/docker-compose.yml b/docker-compose.yml
index 3c9b2f67ff..992a753ac0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,6 +21,8 @@ services:
condition: service_healthy
valkey:
condition: service_healthy
+ neo4j:
+ condition: service_healthy
entrypoint:
- "/home/prowler/docker-entrypoint.sh"
- "prod"
@@ -72,6 +74,37 @@ services:
timeout: 5s
retries: 3
+ neo4j:
+ image: graphstack/dozerdb:5.26.3.0
+ hostname: "neo4j"
+ volumes:
+ - ./_data/neo4j:/data
+ environment:
+ # We can't add our .env file because some of our current variables are not compatible with Neo4j env vars
+ # Auth
+ - NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD}
+ # Memory limits
+ - NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000}
+ - NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE:-1G}
+ - NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G}
+ - NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G}
+ # APOC
+ - apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true}
+ - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true}
+ - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true}
+ - "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}"
+ - "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}"
+ - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}"
+ # Networking
+ - "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}"
+ ports:
+ - ${NEO4J_PORT:-7687}:7687
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "http://localhost:7474"]
+ interval: 10s
+ timeout: 10s
+ retries: 10
+
worker:
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
env_file:
diff --git a/docs/contact.mdx b/docs/contact.mdx
deleted file mode 100644
index 3f898b9049..0000000000
--- a/docs/contact.mdx
+++ /dev/null
@@ -1,11 +0,0 @@
----
-title: 'Contact Us'
----
-
-For technical support or any type of inquiries, you are very welcome to:
-
-- Reach out to community members on the [**Prowler Slack channel**](https://goto.prowler.com/slack)
-
-- Open an Issue or a Pull Request in our [**GitHub repository**](https://github.com/prowler-cloud/prowler).
-
-We will appreciate all types of feedback and contribution, Prowler would not be the same without our vibrant community! π
diff --git a/docs/docs.json b/docs/docs.json
index 1aa7c96fa6..8b2edb1438 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -331,14 +331,28 @@
},
{
"tab": "Security",
- "pages": [
- "security"
+ "groups": [
+ {
+ "group": "Security & Compliance",
+ "pages": [
+ "security/index",
+ "security/software-security"
+ ]
+ },
+ {
+ "group": "Prowler Cloud",
+ "pages": [
+ "security/encryption",
+ "security/data-regions",
+ "security/networking"
+ ]
+ }
]
},
{
- "tab": "Contact Us",
+ "tab": "Support",
"pages": [
- "contact"
+ "support"
]
},
{
@@ -459,6 +473,10 @@
{
"source": "/projects/prowler-open-source/en/latest/tutorials/:slug*",
"destination": "/user-guide/tutorials/:slug*"
+ },
+ {
+ "source": "/contact",
+ "destination": "/support"
}
]
}
diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx
index cd3e18b3ea..2a1d56d605 100644
--- a/docs/getting-started/installation/prowler-app.mdx
+++ b/docs/getting-started/installation/prowler-app.mdx
@@ -115,8 +115,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
-PROWLER_UI_VERSION="5.16.0"
-PROWLER_API_VERSION="5.16.0"
+PROWLER_UI_VERSION="5.17.0"
+PROWLER_API_VERSION="5.17.0"
```
diff --git a/docs/images/providers/grant-admin-consent.png b/docs/images/providers/grant-admin-consent.png
index 0b242308f9..b080cffb3e 100644
Binary files a/docs/images/providers/grant-admin-consent.png and b/docs/images/providers/grant-admin-consent.png differ
diff --git a/docs/images/providers/granted-admin-consent.png b/docs/images/providers/granted-admin-consent.png
new file mode 100644
index 0000000000..ceafbbd675
Binary files /dev/null and b/docs/images/providers/granted-admin-consent.png differ
diff --git a/docs/introduction.mdx b/docs/introduction.mdx
index 928a527a88..eee557b4fc 100644
--- a/docs/introduction.mdx
+++ b/docs/introduction.mdx
@@ -31,11 +31,13 @@ The supported providers right now are:
| [Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) | Official | Clusters | UI, API, CLI |
| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI |
| [Github](/user-guide/providers/github/getting-started-github) | Official | Organizations / Repositories | UI, API, CLI |
-| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI |
-| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI |
-| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
-| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI |
-| **NHN** | Unofficial | Tenants | CLI |
+| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI |
+| [Alibaba Cloud](/user-guide/providers/alibabacloud/getting-started-alibabacloud) | Official | Accounts | UI, API, CLI |
+| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | CLI |
+| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI |
+| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
+| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI |
+| **NHN** | Unofficial | Tenants | CLI |
For more information about the checks and compliance of each provider visit [Prowler Hub](https://hub.prowler.com).
diff --git a/docs/security.mdx b/docs/security.mdx
deleted file mode 100644
index fbf90e63ef..0000000000
--- a/docs/security.mdx
+++ /dev/null
@@ -1,163 +0,0 @@
----
-title: 'Security'
----
-
-## Compliance and Trust
-We publish our live SOC 2 Type 2 Compliance data at [https://trust.prowler.com](https://trust.prowler.com)
-
-As an **AWS Partner**, we have passed the [AWS Foundation Technical Review (FTR)](https://aws.amazon.com/partners/foundational-technical-review/).
-
-
-## Encryption (Prowler Cloud)
-
-We use encryption everywhere possible. The data and communications used by **Prowler Cloud** are **encrypted at-rest** and **in-transit**.
-
-## Data Retention Policy (Prowler Cloud)
-
-Prowler Cloud is GDPR compliant in regards to personal data and the ["right to be forgotten"](https://gdpr.eu/right-to-be-forgotten/). When a user deletes their account their user information will be deleted from Prowler Cloud online and backup systems within 10 calendar days.
-
-## Software Security
-
-We follow a **security-by-design approach** throughout our software development lifecycle. All changes go through automated checks at every stage, from local development to production deployment.
-
-We enforce [pre-commit](https://github.com/prowler-cloud/prowler/blob/master/.pre-commit-config.yaml) validations to catch issues early, and [our CI/CD pipelines](https://github.com/prowler-cloud/prowler/tree/master/.github) include multiple security gates to ensure code quality, secure configurations, and compliance with internal standards.
-
-Our container registries are continuously scanned for vulnerabilities, with findings automatically reported to our security team for assessment and remediation. This process evolves alongside our stack as we adopt new languages, frameworks, and technologies, ensuring our security practices remain comprehensive, proactive, and adaptable.
-
-### Static Application Security Testing (SAST)
-
-We employ multiple SAST tools across our codebase to identify security vulnerabilities, code quality issues, and potential bugs during development:
-
-#### CodeQL Analysis
-- **Scope**: UI (JavaScript/TypeScript), API (Python), and SDK (Python)
-- **Frequency**: On every push and pull request, plus daily scheduled scans
-- **Integration**: Results uploaded to GitHub Security tab via SARIF format
-- **Purpose**: Identifies security vulnerabilities, coding errors, and potential exploits in source code
-
-#### Python Security Scanners
-- **Bandit**: Detects common security issues in Python code (SQL injection, hardcoded passwords, etc.)
- - Configured to ignore test files and report only high-severity issues
- - Runs on both SDK and API codebases
-- **Pylint**: Static code analysis with security-focused checks
- - Integrated into pre-commit hooks and CI/CD pipelines
-
-#### Code Quality & Dead Code Detection
-- **Vulture**: Identifies unused code that could indicate incomplete implementations or security gaps
-- **Flake8**: Style guide enforcement with security-relevant checks
-- **Shellcheck**: Security and correctness checks for shell scripts
-
-### Software Composition Analysis (SCA)
-
-We continuously monitor our dependencies for known vulnerabilities and ensure timely updates:
-
-#### Dependency Vulnerability Scanning
-- **Safety**: Scans Python dependencies against known vulnerability databases
- - Runs on every commit via pre-commit hooks
- - Integrated into CI/CD for SDK and API
- - Configured with selective ignores for tracked exceptions
-- **Trivy**: Multi-purpose scanner for containers and dependencies
- - Scans all container images (UI, API, SDK, MCP Server)
- - Checks for vulnerabilities in OS packages and application dependencies
- - Reports findings to GitHub Security tab
-
-#### Automated Dependency Updates
-- **Dependabot**: Automated pull requests for dependency updates
- - **Python (pip)**: Monthly updates for SDK
- - **GitHub Actions**: Monthly updates for workflow dependencies
- - **Docker**: Monthly updates for base images
- - Temporarily paused for API and UI to maintain stability during active development
- - **Security-first approach**: Even when paused, Dependabot automatically creates pull requests for security vulnerabilities, ensuring critical security patches are never delayed
-
-### Container Security
-
-All container images are scanned before deployment:
-
-- **Trivy Vulnerability Scanning**:
- - Scans images for vulnerabilities and misconfigurations
- - Generates SARIF reports uploaded to GitHub Security tab
- - Creates PR comments with scan summaries
- - Configurable to fail builds on critical findings
- - Reports include CVE counts and remediation guidance
-- **Hadolint**: Dockerfile linting to enforce best practices
- - Validates Dockerfile syntax and structure
- - Ensures secure image building practices
-
-### Secrets Detection
-
-We protect against accidental exposure of sensitive credentials:
-
-- **TruffleHog**: Scans entire codebase and Git history for secrets
- - Runs on every push and pull request
- - Pre-commit hook prevents committing secrets
- - Detects high-entropy strings, API keys, tokens, and credentials
- - Configured to report verified and unknown findings
-
-### Security Monitoring
-
-- **GitHub Security Tab**: Centralized view of all security findings from CodeQL, Trivy, and other SARIF-compatible tools
-- **Artifact Retention**: Security scan reports retained for post-deployment analysis
-- **PR Comments**: Automated security feedback on pull requests for rapid remediation
-
-## Reporting Vulnerabilities
-
-At Prowler, we consider the security of our open source software and systems a top priority. But no matter how much effort we put into system security, there can still be vulnerabilities present.
-
-If you discover a vulnerability, we would like to know about it so we can take steps to address it as quickly as possible. We would like to ask you to help us better protect our users, our clients and our systems.
-
-When reporting vulnerabilities, please consider (1) attack scenario / exploitability, and (2) the security impact of the bug. The following issues are considered out of scope:
-
-- Social engineering support or attacks requiring social engineering.
-- Clickjacking on pages with no sensitive actions.
-- Cross-Site Request Forgery (CSRF) on unauthenticated forms or forms with no sensitive actions.
-- Attacks requiring Man-In-The-Middle (MITM) or physical access to a user's device.
-- Previously known vulnerable libraries without a working Proof of Concept (PoC).
-- Comma Separated Values (CSV) injection without demonstrating a vulnerability.
-- Missing best practices in SSL/TLS configuration.
-- Any activity that could lead to the disruption of service (DoS).
-- Rate limiting or brute force issues on non-authentication endpoints.
-- Missing best practices in Content Security Policy (CSP).
-- Missing HttpOnly or Secure flags on cookies.
-- Configuration of or missing security headers.
-- Missing email best practices, such as invalid, incomplete, or missing SPF/DKIM/DMARC records.
-- Vulnerabilities only affecting users of outdated or unpatched browsers (less than two stable versions behind).
-- Software version disclosure, banner identification issues, or descriptive error messages.
-- Tabnabbing.
-- Issues that require unlikely user interaction.
-- Improper logout functionality and improper session timeout.
-- CORS misconfiguration without an exploitation scenario.
-- Broken link hijacking.
-- Automated scanning results (e.g., sqlmap, Burp active scanner) that have not been manually verified.
-- Content spoofing and text injection issues without a clear attack vector.
-- Email spoofing without exploiting security flaws.
-- Dead links or broken links.
-- User enumeration.
-
-Testing guidelines:
-
-- Do not run automated scanners on other customer projects. Running automated scanners can run up costs for our users. Aggressively configured scanners might inadvertently disrupt services, exploit vulnerabilities, lead to system instability or breaches and violate Terms of Service from our upstream providers. Our own security systems won't be able to distinguish hostile reconnaissance from whitehat research. If you wish to run an automated scanner, notify us at support@prowler.com and only run it on your own Prowler app project. Do NOT attack Prowler in usage of other customers.
-- Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data.
-
-Reporting guidelines:
-
-- File a report through our Support Desk at https://support.prowler.com
-- If it is about a lack of a security functionality, please file a feature request instead at https://github.com/prowler-cloud/prowler/issues
-- Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible.
-- If you have further questions and want direct interaction with the Prowler team, please contact us at via our Community Slack at goto.prowler.com/slack.
-
-Disclosure guidelines:
-
-- In order to protect our users and customers, do not reveal the problem to others until we have researched, addressed and informed our affected customers.
-- If you want to publicly share your research about Prowler at a conference, in a blog or any other public forum, you should share a draft with us for review and approval at least 30 days prior to the publication date. Please note that the following should not be included:
- - Data regarding any Prowler user or customer projects.
- - Prowler customers' data.
- - Information about Prowler employees, contractors or partners.
-
-What we promise:
-
-- We will respond to your report within 5 business days with our evaluation of the report and an expected resolution date.
-- If you have followed the instructions above, we will not take any legal action against you in regard to the report.
-- We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission.
-- We will keep you informed of the progress towards resolving the problem.
-- In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise).
-
-We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it is resolved.
diff --git a/docs/security/data-regions.mdx b/docs/security/data-regions.mdx
new file mode 100644
index 0000000000..0602caf7b9
--- /dev/null
+++ b/docs/security/data-regions.mdx
@@ -0,0 +1,25 @@
+---
+title: 'Data Regions & Availability'
+---
+
+Prowler Cloud runs on AWS with high availability built in.
+
+## Regions
+
+| Region | URL | Location |
+|--------|-----|----------|
+| **EU** | [cloud.prowler.com](https://cloud.prowler.com) | Ireland (`eu-west-1`) |
+
+## Business Continuity
+
+| Control | Details |
+|---------|---------|
+| **High Availability** | Multi-AZ databases and load-balanced stateless application layer on AWS |
+| **Disaster Recovery** | Encrypted backups, tested regularly |
+| **[RPO](https://en.wikipedia.org/wiki/Recovery_point_objective)** | 24 hours |
+| **[RTO](https://en.wikipedia.org/wiki/Recovery_time_objective)** | 2 hours |
+| **Status** | [status.prowler.com](https://status.prowler.com) β uptime history and incidents |
+
+## Contact
+
+For questions about data regions and availability, visit the [Support page](/support).
diff --git a/docs/security/encryption.mdx b/docs/security/encryption.mdx
new file mode 100644
index 0000000000..3c05643069
--- /dev/null
+++ b/docs/security/encryption.mdx
@@ -0,0 +1,25 @@
+---
+title: 'Encryption'
+---
+
+Prowler Cloud uses encryption everywhere possible. All data and communications are encrypted at rest and in transit.
+
+## Encryption at Rest
+
+All data stored in Prowler Cloud is encrypted at rest using AES-256 encryption, including:
+
+- **Database contents:** All scan results, findings, and configuration data.
+- **File storage:** Reports, exports, and uploaded files.
+- **Backups:** All backup data is encrypted.
+
+## Encryption in Transit
+
+All communications with Prowler Cloud are encrypted in transit using TLS 1.2 or higher, including:
+
+- **API requests:** All REST API communications.
+- **Web application traffic:** Browser-to-server connections.
+- **Internal service communication:** Service-to-service traffic within the platform.
+
+## Contact
+
+For questions regarding encryption, visit the [Support page](/support).
diff --git a/docs/security/index.mdx b/docs/security/index.mdx
new file mode 100644
index 0000000000..a9034c4cea
--- /dev/null
+++ b/docs/security/index.mdx
@@ -0,0 +1,76 @@
+---
+title: 'Security & Compliance'
+---
+
+**Prowler secures itself with Prowler.** As an open-source cloud security platform trusted by thousands of organizations, Prowler applies the same rigorous security standards internally that customers achieve externally.
+
+All security tooling, configurations, and CI/CD pipelines are publicly available in the [Prowler GitHub repository](https://github.com/prowler-cloud/prowler). Transparency is fundamental to open-source security.
+
+## Software Security
+
+All Prowler code goes through the same security pipeline, whether running on Prowler Cloud or self-managed infrastructure: DAST, SAST, SCA, container scanning, and secrets detection on every build.
+
+
+ Security tools and practices applied to all Prowler code.
+
+
+## Prowler Cloud vs Self-Managed
+
+| | Prowler Cloud | Self-Managed |
+|--|---------------|--------------|
+| **Deployment** | Fully managed SaaS | Own infrastructure |
+| **Region** | EU (Ireland) | Any region or provider |
+| **Compliance** | SOC 2 Type II, AWS FTR | Organization responsibility |
+| **Data Control** | Prowler managed | Full control |
+| **Encryption** | AES-256 at rest, TLS 1.2+ in transit | Configurable |
+| **Backups** | Automated | Organization responsibility |
+| **Updates** | Automatic | Manual |
+
+
+Self-Managed includes Prowler App and Prowler CLI. They can run anywhere β any cloud provider, any region, on-premises, or air-gapped environments. Full control over data residency and infrastructure decisions. See the [Prowler App Installation Guide](/getting-started/installation/prowler-app) to get started.
+
+
+---
+
+## Prowler Cloud
+
+This section covers security and compliance for **Prowler Cloud**, the managed infrastructure.
+
+### Trust & Compliance
+
+Prowler Cloud holds compliance certifications and undergoes regular audits.
+
+| Certification | Status |
+|---------------|--------|
+| **SOC 2 Type II** | [View on Trust Portal](https://trust.prowler.com) |
+| **AWS Foundational Technical Review (FTR)** | Passed β [Details](https://aws.amazon.com/partners/foundational-technical-review/) |
+
+Compliance data and reports: [trust.prowler.com](https://trust.prowler.com)
+
+### Security
+
+
+
+ Data encrypted at rest (AES-256) and in transit (TLS 1.2+).
+
+
+ EU-hosted infrastructure with high availability and disaster recovery.
+
+
+ Static egress IPs for firewall allowlisting.
+
+
+
+### Privacy
+
+Prowler Cloud is GDPR compliant in regard to the ["right to be forgotten"](https://gdpr.eu/right-to-be-forgotten/). When an account is deleted, user information is removed from online and backup systems within 10 calendar days.
+
+---
+
+## Report a Vulnerability
+
+Found a security issue? Report it through the [responsible disclosure](https://prowler.com/.well-known/security.txt) process.
+
+## Contact
+
+For security inquiries or general support, visit the [Support page](/support).
diff --git a/docs/security/networking.mdx b/docs/security/networking.mdx
new file mode 100644
index 0000000000..767e6ac4bf
--- /dev/null
+++ b/docs/security/networking.mdx
@@ -0,0 +1,21 @@
+---
+title: 'Networking'
+---
+
+## Egress IP Addresses
+
+Prowler Cloud makes outbound API calls to scan cloud provider accounts and connect to integrations. Allowlist these IPs in firewalls or security groups to restrict access to Prowler Cloud only.
+
+| Region | IP Address |
+|--------|------------|
+| EU (Ireland) | `52.48.254.174` |
+
+Resolve the egress IP via DNS:
+
+```bash
+dig egress.prowler.com +short
+```
+
+## Contact
+
+For questions about networking, visit the [Support page](/support).
diff --git a/docs/security/software-security.mdx b/docs/security/software-security.mdx
new file mode 100644
index 0000000000..4c690e988b
--- /dev/null
+++ b/docs/security/software-security.mdx
@@ -0,0 +1,97 @@
+---
+title: 'Software Security'
+---
+
+Prowler follows a **security-by-design approach** throughout the software development lifecycle. All changes go through automated checks at every stage, from local development to production deployment.
+
+[Pre-commit](https://github.com/prowler-cloud/prowler/blob/master/.pre-commit-config.yaml) validations catch issues early, and [CI/CD pipelines](https://github.com/prowler-cloud/prowler/tree/master/.github) include multiple security gates ensuring code quality, secure configurations, and compliance with internal standards.
+
+Container registries are continuously scanned for vulnerabilities, with findings automatically reported to the security team for assessment and remediation. This process evolves alongside the stack as new languages, frameworks, and technologies are adopted, ensuring security practices remain comprehensive, proactive, and adaptable.
+
+## Static Application Security Testing (SAST)
+
+Multiple SAST tools are employed across the codebase to identify security vulnerabilities, code quality issues, and potential bugs during development.
+
+### CodeQL Analysis
+
+- **Scope:** UI (JavaScript/TypeScript), API (Python), and SDK (Python)
+- **Frequency:** On every push and pull request, plus daily scheduled scans
+- **Integration:** Results uploaded to GitHub Security tab via SARIF format
+- **Purpose:** Identifies security vulnerabilities, coding errors, and potential exploits in source code
+
+### Python Security Scanners
+
+- **Bandit:** Detects common security issues in Python code (SQL injection, hardcoded passwords, etc.)
+ - Configured to ignore test files and report only high-severity issues
+ - Runs on both SDK and API codebases
+- **Pylint:** Static code analysis with security-focused checks
+ - Integrated into pre-commit hooks and CI/CD pipelines
+
+### Code Quality & Dead Code Detection
+
+- **Vulture:** Identifies unused code that could indicate incomplete implementations or security gaps
+- **Flake8:** Style guide enforcement with security-relevant checks
+- **Shellcheck:** Security and correctness checks for shell scripts
+
+## Software Composition Analysis (SCA)
+
+Dependencies are continuously monitored for known vulnerabilities with timely updates ensured.
+
+### Dependency Vulnerability Scanning
+
+- **Safety:** Scans Python dependencies against known vulnerability databases
+ - Runs on every commit via pre-commit hooks
+ - Integrated into CI/CD for SDK and API
+ - Configured with selective ignores for tracked exceptions
+- **Trivy:** Multi-purpose scanner for containers and dependencies
+ - Scans all container images (UI, API, SDK, MCP Server)
+ - Checks for vulnerabilities in OS packages and application dependencies
+ - Reports findings to GitHub Security tab
+
+### Automated Dependency Updates
+
+- **Dependabot:** Automated pull requests for dependency updates
+ - **Python (pip):** Monthly updates for SDK
+ - **GitHub Actions:** Monthly updates for workflow dependencies
+ - **Docker:** Monthly updates for base images
+ - Temporarily paused for API and UI to maintain stability during active development
+ - **Security-first approach:** Even when paused, Dependabot automatically creates pull requests for security vulnerabilities, ensuring critical security patches are never delayed
+
+## Container Security
+
+All container images are scanned before deployment.
+
+### Trivy Vulnerability Scanning
+
+- Scans images for vulnerabilities and misconfigurations
+- Generates SARIF reports uploaded to GitHub Security tab
+- Creates PR comments with scan summaries
+- Configurable to fail builds on critical findings
+- Reports include CVE counts and remediation guidance
+
+### Hadolint
+
+- Validates Dockerfile syntax and structure
+- Ensures secure image building practices
+
+## Secrets Detection
+
+Prowler protects against accidental exposure of sensitive credentials.
+
+### TruffleHog
+
+- Scans entire codebase and Git history for secrets
+- Runs on every push and pull request
+- Pre-commit hook prevents committing secrets
+- Detects high-entropy strings, API keys, tokens, and credentials
+- Configured to report verified and unknown findings
+
+## Security Monitoring
+
+- **GitHub Security Tab:** Centralized view of all security findings from CodeQL, Trivy, and other SARIF-compatible tools
+- **Artifact Retention:** Security scan reports retained for post-deployment analysis
+- **PR Comments:** Automated security feedback on pull requests for rapid remediation
+
+## Contact
+
+For questions regarding software security, visit the [Support page](/support).
diff --git a/docs/support.mdx b/docs/support.mdx
new file mode 100644
index 0000000000..6999d5efbf
--- /dev/null
+++ b/docs/support.mdx
@@ -0,0 +1,62 @@
+---
+title: 'Support'
+description: 'Get help with Prowler'
+---
+
+## Lighthouse AI
+
+Lighthouse AI is a Cloud Security Analyst chatbot powered by [Prowler MCP](/getting-started/products/prowler-mcp), your 24/7 virtual cloud security analyst. It can:
+
+- **Query your security data**: Findings, compliance status, resources, and remediation guidance
+- **Search Prowler Hub**: Over 1,000 security checks and 70+ compliance frameworks
+- **Access documentation**: Search and retrieve Prowler docs contextually
+
+Available in Prowler Cloud and Prowler App.
+
+[Learn more about Lighthouse AI](/getting-started/products/prowler-lighthouse-ai)
+
+## Support Desk
+
+> Available to **Prowler Cloud** customers.
+
+For Prowler Cloud customers, submit support requests through our support desk. We'll route your request to the right team and respond via email.
+
+
+ Contact our support team
+
+
+## GitHub Discussions
+
+Prowler is Open Source. If you have a question, it's likely someone else has it too. We'd love to answer in the open on GitHub whenever possible.
+
+
+
+ Get help from the community
+
+
+ Found something wrong? Let us know
+
+
+ Share your ideas for improvements
+
+
+
+## Community Slack
+
+Join our Slack workspace to connect with the Prowler community, ask questions, and get help from other users and the Prowler team.
+
+
+ Connect with the community
+
+
+## Office Hours
+
+Join our open calls to discuss what you're building, ask questions, and connect with the Prowler team and community.
+
+Office Hours sessions are announced on [LinkedIn](https://www.linkedin.com/company/prowler-security/). Recordings of previous sessions are available on [YouTube](https://www.youtube.com/playlist?list=PLIwvjRXuMGkE-BDYXmUR2TXYQ7agxtuB1).
+
+## Security
+
+To report a vulnerability or for security-related inquiries, contact [security@prowler.com](mailto:security@prowler.com).
+
+See also: [Responsible Disclosure](https://prowler.com/.well-known/security.txt)
diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx
index 73f9ff3999..e5039bff21 100644
--- a/docs/user-guide/cli/tutorials/configuration_file.mdx
+++ b/docs/user-guide/cli/tutorials/configuration_file.mdx
@@ -66,6 +66,11 @@ The following list includes all the AWS checks with configurable variables that
| `secretsmanager_secret_rotated_periodically` | `max_days_secret_unrotated` | Integer |
| `ssm_document_secrets` | `secrets_ignore_patterns` | List of Strings |
| `trustedadvisor_premium_support_plan_subscribed` | `verify_premium_support_plans` | Boolean |
+| `dynamodb_table_cross_account_access` | `trusted_account_ids` | List of Strings |
+| `eventbridge_bus_cross_account_access` | `trusted_account_ids` | List of Strings |
+| `eventbridge_schema_registry_cross_account_access` | `trusted_account_ids` | List of Strings |
+| `s3_bucket_cross_account_access` | `trusted_account_ids` | List of Strings |
+| `ssm_documents_set_as_public` | `trusted_account_ids` | List of Strings |
| `vpc_endpoint_connections_trust_boundaries` | `trusted_account_ids` | List of Strings |
| `vpc_endpoint_services_allowed_principals_trust_boundaries` | `trusted_account_ids` | List of Strings |
@@ -202,7 +207,10 @@ aws:
]
# AWS VPC Configuration (vpc_endpoint_connections_trust_boundaries, vpc_endpoint_services_allowed_principals_trust_boundaries)
- # AWS SSM Configuration (aws.ssm_documents_set_as_public)
+ # AWS SSM Configuration (ssm_documents_set_as_public)
+ # AWS S3 Configuration (s3_bucket_cross_account_access)
+ # AWS EventBridge Configuration (eventbridge_schema_registry_cross_account_access, eventbridge_bus_cross_account_access)
+ # AWS DynamoDB Configuration (dynamodb_table_cross_account_access)
# Single account environment: No action required. The AWS account number will be automatically added by the checks.
# Multi account environment: Any additional trusted account number should be added as a space separated list, e.g.
# trusted_account_ids : ["123456789012", "098765432109", "678901234567"]
diff --git a/docs/user-guide/providers/azure/authentication.mdx b/docs/user-guide/providers/azure/authentication.mdx
index 57746e2d60..852e79d115 100644
--- a/docs/user-guide/providers/azure/authentication.mdx
+++ b/docs/user-guide/providers/azure/authentication.mdx
@@ -27,9 +27,9 @@ These permissions allow Prowler to retrieve metadata from the assumed identity a
Assign the following Microsoft Graph permissions:
+- `AuditLog.Read.All`
- `Directory.Read.All`
- `Policy.Read.All`
-- `UserAuthenticationMethod.Read.All` (optional, for multifactor authentication (MFA) checks)
Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permissions. Note that Entra checks related to DirectoryRoles and GetUsers will not run with this permission.
@@ -48,21 +48,22 @@ Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permiss
3. Search and select:
+ - `AuditLog.Read.All`
- `Directory.Read.All`
- `Policy.Read.All`
- - `UserAuthenticationMethod.Read.All`

4. Click "Add permissions", then grant admin consent

+ 
1. To grant permissions to a Service Principal, execute the following command in a terminal:
```console
- az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role 38d9df27-64da-44fd-b7c5-a6fbac20248f=Role
+ az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role b0afded3-3588-46d8-8b3d-9842eff778da=Role
```
@@ -82,17 +83,17 @@ By default, Prowler scans all accessible subscriptions. If you need to audit spe
1. To grant Prowler access to scan a specific Azure subscription, follow these steps in Azure Portal:
Navigate to the subscription you want to audit with Prowler.
- 1. In the left menu, select "Access control (IAM)".
+ 2. In the left menu, select "Access control (IAM)".
- 2. Click "+ Add" and select "Add role assignment".
+ 3. Click "+ Add" and select "Add role assignment".
- 3. In the search bar, enter `Reader`, select it and click "Next".
+ 4. In the search bar, enter `Reader`, select it and click "Next".
- 4. In the "Members" tab, click "+ Select members", then add the accounts to assign this role.
+ 5. In the "Members" tab, click "+ Select members", then add the accounts to assign this role.
- 5. Click "Review + assign" to finalize and apply the role assignment.
+ 6. Click "Review + assign" to finalize and apply the role assignment.
- 
+ 
1. Open a terminal and execute the following command to assign the `Reader` role to the identity that is going to be assumed by Prowler:
@@ -375,7 +376,7 @@ The ProwlerRole is a custom role required for specific security checks. First, c
#### Step 4: (Optional) Assign Microsoft Graph Permissions
-For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and optionally `UserAuthenticationMethod.Read.All`.
+For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and `AuditLog.Read.All`.
Assigning Microsoft Graph API permissions to a Managed Identity requires Azure CLI or PowerShell - it cannot be done through the Azure Portal's standard role assignment interface.
diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx
index e32258eda3..d3c916750e 100644
--- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx
+++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx
@@ -2,6 +2,10 @@
title: 'Getting Started with Cloudflare'
---
+import { VersionBadge } from "/snippets/version-badge.mdx";
+
+
+
Prowler for Cloudflare allows you to scan your Cloudflare zones for security misconfigurations, including SSL/TLS settings, DNSSEC, HSTS, and more.
## Prerequisites
@@ -83,6 +87,30 @@ You can also use zone IDs instead of domain names:
prowler cloudflare -f 023e105f4ecef8ad9ca31a8372d0c353
```
+## Filtering Accounts
+
+By default, Prowler scans all accounts accessible with your credentials. If your API Token or API Key has access to multiple Cloudflare accounts, you can restrict the scan to specific accounts using the `--account-id` argument:
+
+```bash
+prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59
+```
+
+You can specify multiple account IDs:
+
+```bash
+prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 9a7806061c88ada191ed06f989cc3dac
+```
+
+
+If any of the provided account IDs are not found among the accounts accessible with your credentials, Prowler will raise an error and stop execution.
+
+
+You can combine account and zone filtering to narrow the scan scope further:
+
+```bash
+prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 -f example.com
+```
+
## Configuration
Prowler uses a configuration file to customize provider behavior. The Cloudflare configuration includes:
diff --git a/mcp_server/AGENTS.md b/mcp_server/AGENTS.md
index bbefa2db57..c8f77bd4b1 100644
--- a/mcp_server/AGENTS.md
+++ b/mcp_server/AGENTS.md
@@ -1,6 +1,20 @@
# Prowler MCP Server - AI Agent Ruleset
-> **Skills Reference**: For detailed patterns, use the [`prowler-mcp`](../skills/prowler-mcp/SKILL.md) skill.
+> **Skills Reference**: See [`prowler-mcp`](../skills/prowler-mcp/SKILL.md)
+
+### Auto-invoke Skills
+
+When performing these actions, ALWAYS invoke the corresponding skill FIRST:
+
+| Action | Skill |
+|--------|-------|
+| Add changelog entry for a PR or feature | `prowler-changelog` |
+| Committing changes | `prowler-commit` |
+| Create PR that requires changelog entry | `prowler-changelog` |
+| Creating a git commit | `prowler-commit` |
+| Review changelog format and conventions | `prowler-changelog` |
+| Update CHANGELOG.md in any component | `prowler-changelog` |
+| Working on MCP server tools | `prowler-mcp` |
## Project Overview
diff --git a/poetry.lock b/poetry.lock
index a04d0f80a9..f60fce585e 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -741,7 +741,7 @@ version = "4.9.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.9"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"},
{file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"},
@@ -912,19 +912,18 @@ files = [
[[package]]
name = "azure-core"
-version = "1.35.0"
+version = "1.38.0"
description = "Microsoft Azure Core Library for Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1"},
- {file = "azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c"},
+ {file = "azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335"},
+ {file = "azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993"},
]
[package.dependencies]
requests = ">=2.21.0"
-six = ">=1.11.0"
typing-extensions = ">=4.6.0"
[package.extras]
@@ -1512,46 +1511,46 @@ files = [
[[package]]
name = "boto3"
-version = "1.39.15"
+version = "1.40.61"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
- {file = "boto3-1.39.15-py3-none-any.whl", hash = "sha256:38fc54576b925af0075636752de9974e172c8a2cf7133400e3e09b150d20fb6a"},
- {file = "boto3-1.39.15.tar.gz", hash = "sha256:b4483625f0d8c35045254dee46cd3c851bbc0450814f20b9b25bee1b5c0d8409"},
+ {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"},
+ {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"},
]
[package.dependencies]
-botocore = ">=1.39.15,<1.40.0"
+botocore = ">=1.40.61,<1.41.0"
jmespath = ">=0.7.1,<2.0.0"
-s3transfer = ">=0.13.0,<0.14.0"
+s3transfer = ">=0.14.0,<0.15.0"
[package.extras]
crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
-version = "1.39.15"
+version = "1.40.61"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
- {file = "botocore-1.39.15-py3-none-any.whl", hash = "sha256:eb9cfe918ebfbfb8654e1b153b29f0c129d586d2c0d7fb4032731d49baf04cff"},
- {file = "botocore-1.39.15.tar.gz", hash = "sha256:2aa29a717f14f8c7ca058c2e297aaed0aa10ecea24b91514eee802814d1b7600"},
+ {file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"},
+ {file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"},
]
[package.dependencies]
jmespath = ">=0.7.1,<2.0.0"
python-dateutil = ">=2.1,<3.0.0"
urllib3 = [
- {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""},
{version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""},
+ {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""},
]
[package.extras]
-crt = ["awscrt (==0.23.8)"]
+crt = ["awscrt (==0.27.6)"]
[[package]]
name = "cachetools"
@@ -2378,20 +2377,29 @@ testing = ["hatch", "pre-commit", "pytest", "tox"]
[[package]]
name = "filelock"
-version = "3.12.4"
+version = "3.19.1"
description = "A platform independent file lock."
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
groups = ["main", "dev"]
+markers = "python_version < \"3.10\""
files = [
- {file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"},
- {file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"},
+ {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"},
+ {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"},
]
-[package.extras]
-docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"]
-testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"]
-typing = ["typing-extensions (>=4.7.1) ; python_version < \"3.11\""]
+[[package]]
+name = "filelock"
+version = "3.20.3"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.10"
+groups = ["main", "dev"]
+markers = "python_version >= \"3.10\""
+files = [
+ {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
+ {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
+]
[[package]]
name = "flake8"
@@ -2706,7 +2714,7 @@ version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -2746,7 +2754,7 @@ version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
@@ -2783,7 +2791,7 @@ version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -2964,6 +2972,18 @@ files = [
{file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"},
]
+[[package]]
+name = "joblib"
+version = "1.5.3"
+description = "Lightweight pipelining with Python functions"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"},
+ {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"},
+]
+
[[package]]
name = "joserfc"
version = "1.2.2"
@@ -3927,6 +3947,32 @@ extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.1
test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"]
test-extras = ["pytest-mpl", "pytest-randomly"]
+[[package]]
+name = "nltk"
+version = "3.9.2"
+description = "Natural Language Toolkit"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"},
+ {file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"},
+]
+
+[package.dependencies]
+click = "*"
+joblib = "*"
+regex = ">=2021.8.3"
+tqdm = "*"
+
+[package.extras]
+all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"]
+corenlp = ["requests"]
+machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"]
+plot = ["matplotlib"]
+tgrep = ["pyparsing"]
+twitter = ["twython"]
+
[[package]]
name = "nodeenv"
version = "1.9.1"
@@ -4489,36 +4535,6 @@ files = [
{file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"},
]
-[[package]]
-name = "psutil"
-version = "6.0.0"
-description = "Cross-platform lib for process and system monitoring in Python."
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
-groups = ["dev"]
-files = [
- {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"},
- {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"},
- {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"},
- {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"},
- {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"},
- {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"},
- {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"},
- {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"},
- {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"},
- {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"},
- {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"},
- {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"},
- {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"},
- {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"},
- {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"},
- {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"},
- {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"},
-]
-
-[package.extras]
-test = ["enum34 ; python_version <= \"3.4\"", "ipaddress ; python_version < \"3.0\"", "mock ; python_version < \"3.0\"", "pywin32 ; sys_platform == \"win32\"", "wmi ; sys_platform == \"win32\""]
-
[[package]]
name = "py-iam-expand"
version = "0.1.0"
@@ -4568,14 +4584,14 @@ dev = ["black (==22.6.0)", "flake8", "mypy", "pytest"]
[[package]]
name = "pyasn1"
-version = "0.6.1"
+version = "0.6.2"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
- {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
+ {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
+ {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
]
[[package]]
@@ -4612,7 +4628,7 @@ description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
-markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
+markers = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
@@ -5613,6 +5629,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
+ {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
@@ -5621,6 +5638,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
+ {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
@@ -5629,6 +5647,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
+ {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
@@ -5637,6 +5656,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
+ {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
@@ -5645,6 +5665,7 @@ files = [
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
+ {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
@@ -5652,14 +5673,14 @@ files = [
[[package]]
name = "s3transfer"
-version = "0.13.1"
+version = "0.14.0"
description = "An Amazon S3 Transfer Manager"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
- {file = "s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724"},
- {file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"},
+ {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"},
+ {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"},
]
[package.dependencies]
@@ -5670,34 +5691,35 @@ crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
[[package]]
name = "safety"
-version = "3.2.9"
-description = "Checks installed dependencies for known vulnerabilities and licenses."
+version = "3.7.0"
+description = "Scan dependencies for known vulnerabilities and licenses."
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.9"
groups = ["dev"]
files = [
- {file = "safety-3.2.9-py3-none-any.whl", hash = "sha256:5e199c057550dc6146c081084274279dfb98c17735193b028db09a55ea508f1a"},
- {file = "safety-3.2.9.tar.gz", hash = "sha256:494bea752366161ac9e0742033d2a82e4dc51d7c788be42e0ecf5f3ef36b8071"},
+ {file = "safety-3.7.0-py3-none-any.whl", hash = "sha256:65e71db45eb832e8840e3456333d44c23927423753d5610596a09e909a66d2bf"},
+ {file = "safety-3.7.0.tar.gz", hash = "sha256:daec15a393cafc32b846b7ef93f9c952a1708863e242341ab5bde2e4beabb54e"},
]
[package.dependencies]
-Authlib = ">=1.2.0"
-Click = ">=8.0.2"
-dparse = ">=0.6.4b0"
-filelock = ">=3.12.2,<3.13.0"
+authlib = ">=1.2.0"
+click = ">=8.0.2"
+dparse = ">=0.6.4"
+filelock = ">=3.16.1,<4.0"
+httpx = "*"
jinja2 = ">=3.1.0"
marshmallow = ">=3.15.0"
+nltk = ">=3.9"
packaging = ">=21.0"
-psutil = ">=6.0.0,<6.1.0"
-pydantic = ">=1.10.12"
+pydantic = ">=2.6.0"
requests = "*"
-rich = "*"
-"ruamel.yaml" = ">=0.17.21"
-safety-schemas = ">=0.0.4"
-setuptools = ">=65.5.1"
-typer = "*"
+ruamel-yaml = ">=0.17.21"
+safety-schemas = "0.0.16"
+tenacity = ">=8.1.0"
+tomli = {version = "*", markers = "python_version < \"3.11\""}
+tomlkit = "*"
+typer = ">=0.16.0"
typing-extensions = ">=4.7.1"
-urllib3 = ">=1.26.5"
[package.extras]
github = ["pygithub (>=1.43.3)"]
@@ -5706,20 +5728,20 @@ spdx = ["spdx-tools (>=0.8.2)"]
[[package]]
name = "safety-schemas"
-version = "0.0.5"
+version = "0.0.16"
description = "Schemas for Safety tools"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
groups = ["dev"]
files = [
- {file = "safety_schemas-0.0.5-py3-none-any.whl", hash = "sha256:6ac9eb71e60f0d4e944597c01dd48d6d8cd3d467c94da4aba3702a05a3a6ab4f"},
- {file = "safety_schemas-0.0.5.tar.gz", hash = "sha256:0de5fc9a53d4423644a8ce9a17a2e474714aa27e57f3506146e95a41710ff104"},
+ {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"},
+ {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"},
]
[package.dependencies]
-dparse = ">=0.6.4b0"
+dparse = ">=0.6.4"
packaging = ">=21.0"
-pydantic = "*"
+pydantic = ">=2.6.0"
ruamel-yaml = ">=0.17.21"
typing-extensions = ">=4.7.1"
@@ -5804,18 +5826,18 @@ files = [
[[package]]
name = "slack-sdk"
-version = "3.34.0"
+version = "3.39.0"
description = "The Slack API Platform SDK for Python"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
groups = ["main"]
files = [
- {file = "slack_sdk-3.34.0-py2.py3-none-any.whl", hash = "sha256:c61f57f310d85be83466db5a98ab6ae3bb2e5587437b54fa0daa8fae6a0feffa"},
- {file = "slack_sdk-3.34.0.tar.gz", hash = "sha256:ff61db7012160eed742285ea91f11c72b7a38a6500a7f6c5335662b4bc6b853d"},
+ {file = "slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8"},
+ {file = "slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1"},
]
[package.extras]
-optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<15)"]
+optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<16)"]
[[package]]
name = "sniffio"
@@ -5823,7 +5845,7 @@ version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@@ -5889,6 +5911,22 @@ files = [
[package.extras]
widechars = ["wcwidth"]
+[[package]]
+name = "tenacity"
+version = "9.1.2"
+description = "Retry code until it succeeds"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"},
+ {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"},
+]
+
+[package.extras]
+doc = ["reno", "sphinx"]
+test = ["pytest", "tornado (>=4.5)", "typeguard"]
+
[[package]]
name = "tldextract"
version = "5.3.0"
@@ -5966,6 +6004,28 @@ files = [
{file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"},
]
+[[package]]
+name = "tqdm"
+version = "4.67.1"
+description = "Fast, Extensible Progress Meter"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"},
+ {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[package.extras]
+dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"]
+discord = ["requests"]
+notebook = ["ipywidgets (>=6)"]
+slack = ["slack-sdk"]
+telegram = ["requests"]
+
[[package]]
name = "typer"
version = "0.16.0"
@@ -6553,4 +6613,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">3.9.1,<3.13"
-content-hash = "841d93c4db73d37dbc83d2b252bb022917974dc44fc4cca6eb60c41288ad49d9"
+content-hash = "adfc2da2c6e3e803f7a151b9697dbc3f461366a03e4504eb97498cbc72b2e48c"
diff --git a/prowler/AGENTS.md b/prowler/AGENTS.md
index 3b43e1c5d0..4667ba8a28 100644
--- a/prowler/AGENTS.md
+++ b/prowler/AGENTS.md
@@ -13,13 +13,17 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
|--------|-------|
+| Add changelog entry for a PR or feature | `prowler-changelog` |
| Adding new providers | `prowler-provider` |
| Adding services to existing providers | `prowler-provider` |
+| Create PR that requires changelog entry | `prowler-changelog` |
| Creating new checks | `prowler-sdk-check` |
| Creating/updating compliance frameworks | `prowler-compliance` |
| Mapping checks to compliance controls | `prowler-compliance` |
| Mocking AWS with moto in tests | `prowler-test-sdk` |
+| Review changelog format and conventions | `prowler-changelog` |
| Reviewing compliance framework PRs | `prowler-compliance-review` |
+| Update CHANGELOG.md in any component | `prowler-changelog` |
| Updating existing checks and metadata | `prowler-sdk-check` |
| Writing Prowler SDK tests | `prowler-test-sdk` |
| Writing Python tests with pytest | `pytest` |
@@ -28,7 +32,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
## Project Overview
-The Prowler SDK is the core Python engine powering cloud security assessments across AWS, Azure, GCP, Kubernetes, GitHub, M365, and more. It includes 1000+ security checks and 30+ compliance frameworks.
+The Prowler SDK is the core Python engine powering cloud security assessments across AWS, Azure, GCP, Kubernetes, GitHub, M365, and more. It includes 1100+ security checks and 85+ compliance frameworks.
---
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 49112dbd4e..b583a67c30 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -2,11 +2,41 @@
All notable changes to the **Prowler SDK** are documented in this file.
-## [5.17.0] (Prowler UNRELEASED)
+## [5.18.0] (Prowler UNRELEASED)
+
+### π Added
+
+- `defender_zap_for_teams_enabled` check for M365 provider [(#9838)](https://github.com/prowler-cloud/prowler/pull/9838)
+- `compute_instance_suspended_without_persistent_disks` check for GCP provider [(#9747)](https://github.com/prowler-cloud/prowler/pull/9747)
+- `codebuild_project_webhook_filters_use_anchored_patterns` check for AWS provider to detect CodeBreach vulnerability [(#9840)](https://github.com/prowler-cloud/prowler/pull/9840)
+- `exchange_shared_mailbox_sign_in_disabled` check for M365 provider [(#9828)](https://github.com/prowler-cloud/prowler/pull/9828)
+- CloudTrail Timeline abstraction for querying resource modification history [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
+- Cloudflare `--account-id` filter argument [(#9894)](https://github.com/prowler-cloud/prowler/pull/9894)
+- `rds_instance_extended_support` check for AWS provider [(#9865)](https://github.com/prowler-cloud/prowler/pull/9865)
+
+
+### Changed
+
+- Update Azure App Service service metadata to new format [(#9613)](https://github.com/prowler-cloud/prowler/pull/9613)
+- Update Azure Application Insights service metadata to new format [(#9614)](https://github.com/prowler-cloud/prowler/pull/9614)
+- Update Azure Container Registry service metadata to new format [(#9615)](https://github.com/prowler-cloud/prowler/pull/9615)
+- Update Azure Cosmos DB service metadata to new format [(#9616)](https://github.com/prowler-cloud/prowler/pull/9616)
+- Update Azure Databricks service metadata to new format [(#9617)](https://github.com/prowler-cloud/prowler/pull/9617)
+- Parallelize Azure Key Vault vaults and vaults contents retrieval to improve performance [(#9876)](https://github.com/prowler-cloud/prowler/pull/9876)
+- Update Azure IAM service metadata to new format [(#9620)](https://github.com/prowler-cloud/prowler/pull/9620)
+- Update Azure Policy service metadata to new format [(#9625)](https://github.com/prowler-cloud/prowler/pull/9625)
+- Update Azure MySQL service metadata to new format [(#9623)](https://github.com/prowler-cloud/prowler/pull/9623)
+- Update Azure Defender service metadata to new format [(#9618)](https://github.com/prowler-cloud/prowler/pull/9618)
+- Make AWS cross-account checks configurable through `trusted_account_ids` config parameter [(#9692)](https://github.com/prowler-cloud/prowler/pull/9692)
+
+---
+
+## [5.17.0] (Prowler v5.17.0)
### Added
+
- AI Skills pack for AI coding assistants (Claude Code, OpenCode, Codex) following agentskills.io standard [(#9728)](https://github.com/prowler-cloud/prowler/pull/9728)
-- Add Prowler ThreatScore for the Alibaba Cloud provider [(#9511)](https://github.com/prowler-cloud/prowler/pull/9511)
+- Prowler ThreatScore for the Alibaba Cloud provider [(#9511)](https://github.com/prowler-cloud/prowler/pull/9511)
- `compute_instance_group_multiple_zones` check for GCP provider [(#9566)](https://github.com/prowler-cloud/prowler/pull/9566)
- `compute_instance_group_autohealing_enabled` check for GCP provider [(#9690)](https://github.com/prowler-cloud/prowler/pull/9690)
- Support AWS European Sovereign Cloud [(#9649)](https://github.com/prowler-cloud/prowler/pull/9649)
@@ -16,13 +46,20 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `compute_configuration_changes` check for GCP provider to detect Compute Engine configuration changes in Cloud Audit Logs [(#9698)](https://github.com/prowler-cloud/prowler/pull/9698)
- `compute_instance_group_load_balancer_attached` check for GCP provider [(#9695)](https://github.com/prowler-cloud/prowler/pull/9695)
- `Cloudflare` provider with critical security checks [(#9423)](https://github.com/prowler-cloud/prowler/pull/9423)
+- CloudFlare `TLS/SSL`, `records` and `email` checks for `zone` service [(#9424)](https://github.com/prowler-cloud/prowler/pull/9424)
- `compute_instance_single_network_interface` check for GCP provider [(#9702)](https://github.com/prowler-cloud/prowler/pull/9702)
- `compute_image_not_publicly_shared` check for GCP provider [(#9718)](https://github.com/prowler-cloud/prowler/pull/9718)
+- `compute_snapshot_not_outdated` check for GCP provider [(#9774)](https://github.com/prowler-cloud/prowler/pull/9774)
+- `compute_project_os_login_2fa_enabled` check for GCP provider [(#9839)](https://github.com/prowler-cloud/prowler/pull/9839)
+- `compute_instance_on_host_maintenance_migrate` check for GCP provider [(#9834)](https://github.com/prowler-cloud/prowler/pull/9834)
- CIS 1.12 compliance framework for Kubernetes [(#9778)](https://github.com/prowler-cloud/prowler/pull/9778)
- CIS 6.0 for M365 provider [(#9779)](https://github.com/prowler-cloud/prowler/pull/9779)
- CIS 5.0 compliance framework for the Azure provider [(#9777)](https://github.com/prowler-cloud/prowler/pull/9777)
+- `Cloudflare` Bot protection, WAF, Privacy, Anti-Scraping and Zone configuration checks [(#9425)](https://github.com/prowler-cloud/prowler/pull/9425)
+- `Cloudflare` `waf` and `dns record` checks [(#9426)](https://github.com/prowler-cloud/prowler/pull/9426)
### Changed
+
- Update AWS Step Functions service metadata to new format [(#9432)](https://github.com/prowler-cloud/prowler/pull/9432)
- Update AWS Route 53 service metadata to new format [(#9406)](https://github.com/prowler-cloud/prowler/pull/9406)
- Update AWS SQS service metadata to new format [(#9429)](https://github.com/prowler-cloud/prowler/pull/9429)
@@ -48,21 +85,28 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Update AWS RDS service metadata to new format [(#9551)](https://github.com/prowler-cloud/prowler/pull/9551)
- Update AWS Bedrock service metadata to new format [(#8827)](https://github.com/prowler-cloud/prowler/pull/8827)
- Update AWS IAM service metadata to new format [(#9550)](https://github.com/prowler-cloud/prowler/pull/9550)
+- Enhance `user_registration_details` perfomance and user `mfa` evaluation [(#9236)](https://github.com/prowler-cloud/prowler/pull/9236)
- Update AWS Cognito service metadata to new format [(#8853)](https://github.com/prowler-cloud/prowler/pull/8853)
-
----
-
-## [5.16.2] (Prowler v5.16.2) (UNRELEASED)
+- Update AWS EC2 service metadata to new format [(#9549)](https://github.com/prowler-cloud/prowler/pull/9549)
+- Update Azure AI Search service metadata to new format [(#9087)](https://github.com/prowler-cloud/prowler/pull/9087)
+- Update Azure AKS service metadata to new format [(#9611)](https://github.com/prowler-cloud/prowler/pull/9611)
+- Update Azure API Management service metadata to new format [(#9612)](https://github.com/prowler-cloud/prowler/pull/9612)
### Fixed
-- Fix OCI authentication error handling and validation [(#9738)](https://github.com/prowler-cloud/prowler/pull/9738)
-- Fixup AWS EC2 SG library [(#9216)](https://github.com/prowler-cloud/prowler/pull/9216)
+
+- OCI authentication error handling and validation [(#9738)](https://github.com/prowler-cloud/prowler/pull/9738)
+- AWS EC2 SG library [(#9216)](https://github.com/prowler-cloud/prowler/pull/9216)
+
+### Security
+- `safety` to `3.7.0` and `filelock` to `3.20.3` due to [Safety vulnerability 82754 (CVE-2025-68146)](https://data.safetycli.com/v/82754/97c/) [(#9816)](https://github.com/prowler-cloud/prowler/pull/9816)
+- `pyasn1` to v0.6.2 to address [CVE-2026-23490](https://nvd.nist.gov/vuln/detail/CVE-2026-23490) [(#9817)](https://github.com/prowler-cloud/prowler/pull/9817)
---
## [5.16.1] (Prowler v5.16.1)
### Fixed
+
- ZeroDivision error from Prowler ThreatScore [(#9653)](https://github.com/prowler-cloud/prowler/pull/9653)
---
@@ -70,10 +114,12 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.16.0] (Prowler v5.16.0)
### Added
+
- `privilege-escalation` and `ec2-imdsv1` categories for AWS checks [(#9537)](https://github.com/prowler-cloud/prowler/pull/9537)
- Supported IaC formats and scanner documentation for the IaC provider [(#9553)](https://github.com/prowler-cloud/prowler/pull/9553)
### Changed
+
- Update AWS Glue service metadata to new format [(#9258)](https://github.com/prowler-cloud/prowler/pull/9258)
- Update AWS Kafka service metadata to new format [(#9261)](https://github.com/prowler-cloud/prowler/pull/9261)
- Update AWS KMS service metadata to new format [(#9263)](https://github.com/prowler-cloud/prowler/pull/9263)
@@ -86,6 +132,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Update AWS WAF v2 service metadata to new format [(#9481)](https://github.com/prowler-cloud/prowler/pull/9481)
### Fixed
+
- Fix typo `trustboundaries` category to `trust-boundaries` [(#9536)](https://github.com/prowler-cloud/prowler/pull/9536)
- Fix incorrect `bedrock-agent` regional availability, now using official AWS docs instead of copying from `bedrock`
- Store MongoDB Atlas provider regions as lowercase [(#9554)](https://github.com/prowler-cloud/prowler/pull/9554)
@@ -96,6 +143,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.15.1] (Prowler v5.15.1)
### Fixed
+
- Fix false negative in AWS `apigateway_restapi_logging_enabled` check by refining stage logging evaluation to ensure logging level is not set to "OFF" [(#9304)](https://github.com/prowler-cloud/prowler/pull/9304)
---
@@ -103,6 +151,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.15.0] (Prowler v5.15.0)
### Added
+
- `cloudstorage_uses_vpc_service_controls` check for GCP provider [(#9256)](https://github.com/prowler-cloud/prowler/pull/9256)
- Alibaba Cloud provider with CIS 2.0 benchmark [(#9329)](https://github.com/prowler-cloud/prowler/pull/9329)
- `repository_immutable_releases_enabled` check for GitHub provider [(#9162)](https://github.com/prowler-cloud/prowler/pull/9162)
@@ -116,6 +165,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- RBI Cyber Security Framework compliance for Azure provider [(#8822)](https://github.com/prowler-cloud/prowler/pull/8822)
### Changed
+
- Update AWS Macie service metadata to new format [(#9265)](https://github.com/prowler-cloud/prowler/pull/9265)
- Update AWS Lightsail service metadata to new format [(#9264)](https://github.com/prowler-cloud/prowler/pull/9264)
- Update AWS GuardDuty service metadata to new format [(#9259)](https://github.com/prowler-cloud/prowler/pull/9259)
@@ -125,6 +175,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Update AWS Lightsail service metadata to new format [(#9264)](https://github.com/prowler-cloud/prowler/pull/9264)
### Fixed
+
- Fix duplicate requirement IDs in ISO 27001:2013 AWS compliance framework by adding unique letter suffixes
- Removed incorrect threat-detection category from checks metadata [(#9489)](https://github.com/prowler-cloud/prowler/pull/9489)
- GCP `cloudstorage_uses_vpc_service_controls` check to handle VPC Service Controls blocked API access [(#9478)](https://github.com/prowler-cloud/prowler/pull/9478)
@@ -134,6 +185,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.14.2] (Prowler v5.14.2)
### Fixed
+
- Custom check folder metadata validation [(#9335)](https://github.com/prowler-cloud/prowler/pull/9335)
- Pin `alibabacloud-gateway-oss-util` to version 0.0.3 to address missing dependency [(#9487)](https://github.com/prowler-cloud/prowler/pull/9487)
@@ -142,6 +194,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.14.1] (Prowler v5.14.1)
### Fixed
+
- `sharepoint_external_sharing_managed` check to handle external sharing disabled at organization level [(#9298)](https://github.com/prowler-cloud/prowler/pull/9298)
- Support multiple Exchange mailbox policies in M365 `exchange_mailbox_policy_additional_storage_restricted` check [(#9241)](https://github.com/prowler-cloud/prowler/pull/9241)
@@ -150,6 +203,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.14.0] (Prowler v5.14.0)
### Added
+
- GitHub provider check `organization_default_repository_permission_strict` [(#8785)](https://github.com/prowler-cloud/prowler/pull/8785)
- Add OCI mapping to scan and check classes [(#8927)](https://github.com/prowler-cloud/prowler/pull/8927)
- `codepipeline_project_repo_private` check for AWS provider [(#5915)](https://github.com/prowler-cloud/prowler/pull/5915)
@@ -175,6 +229,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add branch name to IaC provider region [(#9296)](https://github.com/prowler-cloud/prowler/pull/9295)
### Changed
+
- Update AWS Direct Connect service metadata to new format [(#8855)](https://github.com/prowler-cloud/prowler/pull/8855)
- Update AWS DRS service metadata to new format [(#8870)](https://github.com/prowler-cloud/prowler/pull/8870)
- Update AWS DynamoDB service metadata to new format [(#8871)](https://github.com/prowler-cloud/prowler/pull/8871)
@@ -208,9 +263,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Update AWS ECS service metadata to new format [(#8888)](https://github.com/prowler-cloud/prowler/pull/8888)
- Update AWS Kinesis service metadata to new format [(#9262)](https://github.com/prowler-cloud/prowler/pull/9262)
- Update AWS DocumentDB service metadata to new format [(#8862)](https://github.com/prowler-cloud/prowler/pull/8862)
-
+- Adapt IaC provider to be used in the Prowler App [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751)
### Fixed
+
- Check `check_name` has no `resource_name` error for GCP provider [(#9169)](https://github.com/prowler-cloud/prowler/pull/9169)
- Depth Truncation and parsing error in PowerShell queries [(#9181)](https://github.com/prowler-cloud/prowler/pull/9181)
- False negative in `iam_role_cross_service_confused_deputy_prevention` check [(#9213)](https://github.com/prowler-cloud/prowler/pull/9213)
@@ -228,6 +284,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.13.1] (Prowler v5.13.1)
### Fixed
+
- Add `resource_name` for checks under `logging` for the GCP provider [(#9023)](https://github.com/prowler-cloud/prowler/pull/9023)
- Fix `ec2_instance_with_outdated_ami` check to handle None AMIs [(#9046)](https://github.com/prowler-cloud/prowler/pull/9046)
- Handle timestamp when transforming compliance findings in CCC [(#9042)](https://github.com/prowler-cloud/prowler/pull/9042)
@@ -236,14 +293,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
---
-### Changed
-- Adapt IaC provider to be used in the Prowler App [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751)
-
----
-
## [5.13.0] (Prowler v5.13.0)
### Added
+
- Support for AdditionalURLs in outputs [(#8651)](https://github.com/prowler-cloud/prowler/pull/8651)
- Support for markdown metadata fields in Dashboard [(#8667)](https://github.com/prowler-cloud/prowler/pull/8667)
- `ec2_instance_with_outdated_ami` check for AWS provider [(#6910)](https://github.com/prowler-cloud/prowler/pull/6910)
@@ -286,6 +339,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### Fixed
+
- Fix SNS topics showing empty AWS_ResourceID in Quick Inventory output [(#8762)](https://github.com/prowler-cloud/prowler/issues/8762)
- Fix HTML Markdown output for long strings [(#8803)](https://github.com/prowler-cloud/prowler/pull/8803)
- Prowler ThreatScore scoring calculation CLI [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
@@ -302,6 +356,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.12.1] (Prowler v5.12.1)
### Fixed
+
- Replaced old check id with new ones for compliance files [(#8682)](https://github.com/prowler-cloud/prowler/pull/8682)
- `firehose_stream_encrypted_at_rest` check false positives and new api call in kafka service [(#8599)](https://github.com/prowler-cloud/prowler/pull/8599)
- Replace defender rules policies key to use old name [(#8702)](https://github.com/prowler-cloud/prowler/pull/8702)
@@ -311,6 +366,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.12.0] (Prowler v5.12.0)
### Added
+
- Add more fields for the Jira ticket and handle custom fields errors [(#8601)](https://github.com/prowler-cloud/prowler/pull/8601)
- Support labels on Jira tickets [(#8603)](https://github.com/prowler-cloud/prowler/pull/8603)
- Add finding url and tenant info inside Jira tickets [(#8607)](https://github.com/prowler-cloud/prowler/pull/8607)
@@ -334,9 +390,11 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `projects_network_access_list_exposed_to_internet` - Ensure project network access list is not exposed to internet
### Changed
+
- Rename ftp and mongo checks to follow pattern `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_*` [(#8293)](https://github.com/prowler-cloud/prowler/pull/8293)
### Fixed
+
- Renamed `AdditionalUrls` to `AdditionalURLs` field in CheckMetadata [(#8639)](https://github.com/prowler-cloud/prowler/pull/8639)
- TypeError from Python 3.9 in Security Hub module by updating type annotations [(#8619)](https://github.com/prowler-cloud/prowler/pull/8619)
- KeyError when SecurityGroups field is missing in MemoryDB check [(#8666)](https://github.com/prowler-cloud/prowler/pull/8666)
@@ -347,6 +405,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.11.0] (Prowler v5.11.0)
### Added
+
- Certificate authentication for M365 provider [(#8404)](https://github.com/prowler-cloud/prowler/pull/8404)
- `vm_sufficient_daily_backup_retention_period` check for Azure provider [(#8200)](https://github.com/prowler-cloud/prowler/pull/8200)
- `vm_jit_access_enabled` check for Azure provider [(#8202)](https://github.com/prowler-cloud/prowler/pull/8202)
@@ -361,10 +420,12 @@ All notable changes to the **Prowler SDK** are documented in this file.
- GCP `--skip-api-check` command line flag [(#8575)](https://github.com/prowler-cloud/prowler/pull/8575)
### Changed
+
- Refine kisa isms-p compliance mapping [(#8479)](https://github.com/prowler-cloud/prowler/pull/8479)
- Improve AWS Security Hub region check using multiple threads [(#8365)](https://github.com/prowler-cloud/prowler/pull/8365)
### Fixed
+
- Resource metadata error in `s3_bucket_shadow_resource_vulnerability` check [(#8572)](https://github.com/prowler-cloud/prowler/pull/8572)
- GitHub App authentication through API fails with auth_method validation error [(#8587)](https://github.com/prowler-cloud/prowler/pull/8587)
- AWS resource-arn filtering [(#8533)](https://github.com/prowler-cloud/prowler/pull/8533)
@@ -378,6 +439,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.10.2] (Prowler v5.10.2)
### Fixed
+
- Order requirements by ID in Prowler ThreatScore AWS compliance framework [(#8495)](https://github.com/prowler-cloud/prowler/pull/8495)
- Add explicit resource name to GCP and Azure Defender checks [(#8352)](https://github.com/prowler-cloud/prowler/pull/8352)
- Validation errors in Azure and M365 providers [(#8353)](https://github.com/prowler-cloud/prowler/pull/8353)
@@ -392,6 +454,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.10.1] (Prowler v5.10.1)
### Fixed
+
- Remove invalid requirements from CIS 1.0 for GitHub provider [(#8472)](https://github.com/prowler-cloud/prowler/pull/8472)
---
@@ -399,6 +462,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.10.0] (Prowler v5.10.0)
### Added
+
- `bedrock_api_key_no_administrative_privileges` check for AWS provider [(#8321)](https://github.com/prowler-cloud/prowler/pull/8321)
- `bedrock_api_key_no_long_term_credentials` check for AWS provider [(#8396)](https://github.com/prowler-cloud/prowler/pull/8396)
- Support App Key Content in GitHub provider [(#8271)](https://github.com/prowler-cloud/prowler/pull/8271)
@@ -411,11 +475,13 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Use `trivy` as engine for IaC provider [(#8466)](https://github.com/prowler-cloud/prowler/pull/8466)
### Changed
+
- Handle some AWS errors as warnings instead of errors [(#8347)](https://github.com/prowler-cloud/prowler/pull/8347)
- Revert import of `checkov` python library [(#8385)](https://github.com/prowler-cloud/prowler/pull/8385)
- Updated policy mapping in ISMS-P compliance file for improved alignment [(#8367)](https://github.com/prowler-cloud/prowler/pull/8367)
### Fixed
+
- False positives in SQS encryption check for ephemeral queues [(#8330)](https://github.com/prowler-cloud/prowler/pull/8330)
- Add protocol validation check in security group checks to ensure proper protocol matching [(#8374)](https://github.com/prowler-cloud/prowler/pull/8374)
- Add missing audit evidence for controls 1.1.4 and 2.5.5 for ISMS-P compliance. [(#8386)](https://github.com/prowler-cloud/prowler/pull/8386)
@@ -439,6 +505,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.9.2] (Prowler v5.9.2)
### Fixed
+
- Use the correct resource name in `defender_domain_dkim_enabled` check [(#8334)](https://github.com/prowler-cloud/prowler/pull/8334)
---
@@ -446,6 +513,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.9.0] (Prowler v5.9.0)
### Added
+
- `storage_smb_channel_encryption_with_secure_algorithm` check for Azure provider [(#8123)](https://github.com/prowler-cloud/prowler/pull/8123)
- `storage_smb_protocol_version_is_latest` check for Azure provider [(#8128)](https://github.com/prowler-cloud/prowler/pull/8128)
- `vm_backup_enabled` check for Azure provider [(#8182)](https://github.com/prowler-cloud/prowler/pull/8182)
@@ -458,9 +526,11 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add `test_connection` method to GitHub provider [(#8248)](https://github.com/prowler-cloud/prowler/pull/8248)
### Changed
+
- Refactor the Azure Defender get security contact configuration method to use the API REST endpoint instead of the SDK [(#8241)](https://github.com/prowler-cloud/prowler/pull/8241)
### Fixed
+
- Title & description wording for `iam_user_accesskey_unused` check for AWS provider [(#8233)](https://github.com/prowler-cloud/prowler/pull/8233)
- Add GitHub provider to lateral panel in documentation and change -h environment variable output [(#8246)](https://github.com/prowler-cloud/prowler/pull/8246)
- Show `m365_identity_type` and `m365_identity_id` in cloud reports [(#8247)](https://github.com/prowler-cloud/prowler/pull/8247)
@@ -480,6 +550,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.8.1] (Prowler v5.8.1)
### Fixed
+
- Detect wildcarded ARNs in sts:AssumeRole policy resources [(#8164)](https://github.com/prowler-cloud/prowler/pull/8164)
- List all streams and `firehose_stream_encrypted_at_rest` logic [(#8213)](https://github.com/prowler-cloud/prowler/pull/8213)
- Allow empty values for http_endpoint in templates [(#8184)](https://github.com/prowler-cloud/prowler/pull/8184)
@@ -532,6 +603,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- New check `codebuild_project_not_publicly_accessible` for AWS provider [(#8127)](https://github.com/prowler-cloud/prowler/pull/8127)
### Fixed
+
- Consolidate Azure Storage file service properties to the account level, improving the accuracy of the `storage_ensure_file_shares_soft_delete_is_enabled` check [(#8087)](https://github.com/prowler-cloud/prowler/pull/8087)
- Migrate Azure VM service and managed disk logic to Pydantic models for better serialization and type safety, and update all related tests to use the new models and fix UUID handling [(#https://github.com/prowler-cloud/prowler/pull/8151)](https://github.com/prowler-cloud/prowler/pull/https://github.com/prowler-cloud/prowler/pull/8151)
- `organizations_scp_check_deny_regions` check to pass when SCP policies have no statements [(#8091)](https://github.com/prowler-cloud/prowler/pull/8091)
@@ -542,9 +614,11 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Handle empty name in Azure Defender and GCP checks [(#8120)](https://github.com/prowler-cloud/prowler/pull/8120)
### Changed
+
- Reworked `S3.test_connection` to match the AwsProvider logic [(#8088)](https://github.com/prowler-cloud/prowler/pull/8088)
### Removed
+
- OCSF version number references to point always to the latest [(#8064)](https://github.com/prowler-cloud/prowler/pull/8064)
---
@@ -552,6 +626,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.7.5] (Prowler v5.7.5)
### Fixed
+
- Use unified timestamp for all requirements [(#8059)](https://github.com/prowler-cloud/prowler/pull/8059)
- Add EKS to service without subservices [(#7959)](https://github.com/prowler-cloud/prowler/pull/7959)
- `apiserver_strong_ciphers_only` check for K8S provider [(#7952)](https://github.com/prowler-cloud/prowler/pull/7952)
@@ -570,6 +645,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.7.3] (Prowler v5.7.3)
### Fixed
+
- Automatically encrypt password in Microsoft365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784)
- Remove last encrypted password appearances [(#7825)](https://github.com/prowler-cloud/prowler/pull/7825)
@@ -578,6 +654,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.7.2] (Prowler v5.7.2)
### Fixed
+
- `m365_powershell test_credentials` to use sanitized credentials [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761)
- `admincenter_users_admins_reduced_license_footprint` check logic to pass when admin user has no license [(#7779)](https://github.com/prowler-cloud/prowler/pull/7779)
- `m365_powershell` to close the PowerShell sessions in msgraph services [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816)
@@ -590,6 +667,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.7.0] (Prowler v5.7.0)
### Added
+
- Update the compliance list supported for each provider from docs [(#7694)](https://github.com/prowler-cloud/prowler/pull/7694)
- Allow setting cluster name in in-cluster mode in Kubernetes [(#7695)](https://github.com/prowler-cloud/prowler/pull/7695)
- Prowler ThreatScore for M365 provider [(#7692)](https://github.com/prowler-cloud/prowler/pull/7692)
@@ -608,6 +686,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- CIS 5.0 compliance framework for AWS [(7766)](https://github.com/prowler-cloud/prowler/pull/7766)
### Fixed
+
- Update CIS 4.0 for M365 provider [(#7699)](https://github.com/prowler-cloud/prowler/pull/7699)
- Update and upgrade CIS for all the providers [(#7738)](https://github.com/prowler-cloud/prowler/pull/7738)
- Cover policies with conditions with SNS endpoint in `sns_topics_not_publicly_accessible` [(#7750)](https://github.com/prowler-cloud/prowler/pull/7750)
@@ -618,6 +697,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.6.0] (Prowler v5.6.0)
### Added
+
- SOC2 compliance framework to Azure [(#7489)](https://github.com/prowler-cloud/prowler/pull/7489)
- Check for unused Service Accounts in GCP [(#7419)](https://github.com/prowler-cloud/prowler/pull/7419)
- Powershell to Microsoft365 [(#7331)](https://github.com/prowler-cloud/prowler/pull/7331)
@@ -667,6 +747,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Microsoft User and User Credential auth to reports [(#7681)](https://github.com/prowler-cloud/prowler/pull/7681)
### Fixed
+
- Package name location in pyproject.toml while replicating for prowler-cloud [(#7531)](https://github.com/prowler-cloud/prowler/pull/7531)
- Remove cache in PyPI release action [(#7532)](https://github.com/prowler-cloud/prowler/pull/7532)
- The correct values for logger.info inside iam service [(#7526)](https://github.com/prowler-cloud/prowler/pull/7526)
@@ -687,6 +768,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.5.1] (Prowler v5.5.1)
### Fixed
+
- Default name to contacts in Azure Defender [(#7483)](https://github.com/prowler-cloud/prowler/pull/7483)
- Handle projects without ID in GCP [(#7496)](https://github.com/prowler-cloud/prowler/pull/7496)
- Restore packages location in PyProject [(#7510)](https://github.com/prowler-cloud/prowler/pull/7510)
diff --git a/prowler/compliance/m365/cis_4.0_m365.json b/prowler/compliance/m365/cis_4.0_m365.json
index 96a7932baf..156c17e26d 100644
--- a/prowler/compliance/m365/cis_4.0_m365.json
+++ b/prowler/compliance/m365/cis_4.0_m365.json
@@ -121,7 +121,9 @@
{
"Id": "1.2.2",
"Description": "Shared mailboxes are used when multiple people need access to the same mailbox, such as a company information or support email address, reception desk, or other function that might be shared by multiple people.Users with permissions to the group mailbox can send as or send on behalf of the mailbox email address if the administrator has given that user permissions to do that. This is particularly useful for help and support mailboxes because users can send emails from \"Contoso Support\" or \"Building A Reception Desk.\"Shared mailboxes are created with a corresponding user account using a system generated password that is unknown at the time of creation.The recommended state is `Sign in blocked` for `Shared mailboxes`.",
- "Checks": [],
+ "Checks": [
+ "exchange_shared_mailbox_sign_in_disabled"
+ ],
"Attributes": [
{
"Section": "1 Microsoft 365 admin center",
@@ -691,7 +693,9 @@
{
"Id": "2.4.4",
"Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes malware and high confidence phishing. When ZAP for Teams protection blocks a message, the message is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP occurs up to 48 hours after delivery.",
- "Checks": [],
+ "Checks": [
+ "defender_zap_for_teams_enabled"
+ ],
"Attributes": [
{
"Section": "2 Microsoft 365 Defender",
diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json
index ea97cf10e6..93fe2e2226 100644
--- a/prowler/compliance/m365/cis_6.0_m365.json
+++ b/prowler/compliance/m365/cis_6.0_m365.json
@@ -121,7 +121,9 @@
{
"Id": "1.2.2",
"Description": "Shared mailboxes are used when multiple people need access to the same mailbox, such as a company information or support email address, reception desk, or other function that might be shared by multiple people. Shared mailboxes are created with a corresponding user account using a system generated password that is unknown at the time of creation. The recommended state is Sign in blocked for Shared mailboxes.",
- "Checks": [],
+ "Checks": [
+ "exchange_shared_mailbox_sign_in_disabled"
+ ],
"Attributes": [
{
"Section": "1 Microsoft 365 admin center",
@@ -753,7 +755,9 @@
{
"Id": "2.4.4",
"Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes malware and high confidence phishing. When ZAP for Teams protection blocks a message, the message is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP occurs up to 48 hours after delivery.",
- "Checks": [],
+ "Checks": [
+ "defender_zap_for_teams_enabled"
+ ],
"Attributes": [
{
"Section": "2 Microsoft 365 Defender",
diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json
index 5e5d39c241..81ae9dea2a 100644
--- a/prowler/compliance/m365/iso27001_2022_m365.json
+++ b/prowler/compliance/m365/iso27001_2022_m365.json
@@ -112,12 +112,13 @@
}
],
"Checks": [
- "entra_identity_protection_sign_in_risk_enabled",
- "entra_identity_protection_user_risk_enabled",
+ "defender_antiphishing_policy_configured",
"defender_antispam_outbound_policy_configured",
"defender_malware_policy_notifications_internal_users_malware_enabled",
- "defender_antiphishing_policy_configured",
- "entra_admin_users_phishing_resistant_mfa_enabled"
+ "defender_zap_for_teams_enabled",
+ "entra_admin_users_phishing_resistant_mfa_enabled",
+ "entra_identity_protection_sign_in_risk_enabled",
+ "entra_identity_protection_user_risk_enabled"
]
},
{
@@ -344,15 +345,15 @@
}
],
"Checks": [
- "defender_antispam_outbound_policy_configured",
- "defender_malware_policy_notifications_internal_users_malware_enabled",
- "defender_malware_policy_common_attachments_filter_enabled",
- "defender_malware_policy_comprehensive_attachments_filter_applied",
"defender_antispam_connection_filter_policy_empty_ip_allowlist",
"defender_antispam_connection_filter_policy_safe_list_off",
"defender_antispam_outbound_policy_configured",
"defender_antispam_outbound_policy_forwarding_disabled",
- "defender_antispam_policy_inbound_no_allowed_domains"
+ "defender_antispam_policy_inbound_no_allowed_domains",
+ "defender_malware_policy_common_attachments_filter_enabled",
+ "defender_malware_policy_comprehensive_attachments_filter_applied",
+ "defender_malware_policy_notifications_internal_users_malware_enabled",
+ "defender_zap_for_teams_enabled"
]
},
{
@@ -368,11 +369,11 @@
}
],
"Checks": [
+ "defender_antispam_outbound_policy_configured",
"defender_malware_policy_common_attachments_filter_enabled",
"defender_malware_policy_comprehensive_attachments_filter_applied",
"defender_malware_policy_notifications_internal_users_malware_enabled",
- "defender_antispam_outbound_policy_configured",
- "defender_malware_policy_notifications_internal_users_malware_enabled"
+ "defender_zap_for_teams_enabled"
]
},
{
@@ -691,6 +692,7 @@
"defender_malware_policy_common_attachments_filter_enabled",
"defender_malware_policy_comprehensive_attachments_filter_applied",
"defender_malware_policy_notifications_internal_users_malware_enabled",
+ "defender_zap_for_teams_enabled",
"teams_external_domains_restricted",
"teams_external_users_cannot_start_conversations"
]
diff --git a/prowler/config/cloudflare_mutelist_example.yaml b/prowler/config/cloudflare_mutelist_example.yaml
index dc7bb4c91e..ad98637cd2 100644
--- a/prowler/config/cloudflare_mutelist_example.yaml
+++ b/prowler/config/cloudflare_mutelist_example.yaml
@@ -10,7 +10,7 @@ Mutelist:
Accounts:
"example-account-id":
Checks:
- "zones_dnssec_enabled":
+ "zone_dnssec_enabled":
Regions:
- "*"
Resources:
diff --git a/prowler/config/config.py b/prowler/config/config.py
index a8ce986457..2b9a5547bf 100644
--- a/prowler/config/config.py
+++ b/prowler/config/config.py
@@ -38,7 +38,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
-prowler_version = "5.17.0"
+prowler_version = "5.18.0"
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"
diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml
index ab5cce8ba9..4682afc7af 100644
--- a/prowler/config/config.yaml
+++ b/prowler/config/config.yaml
@@ -63,7 +63,10 @@ aws:
fargate_windows_latest_version: "1.0.0"
# AWS VPC Configuration (vpc_endpoint_connections_trust_boundaries, vpc_endpoint_services_allowed_principals_trust_boundaries)
- # AWS SSM Configuration (aws.ssm_documents_set_as_public)
+ # AWS SSM Configuration (ssm_documents_set_as_public)
+ # AWS S3 Configuration (s3_bucket_cross_account_access)
+ # AWS EventBridge Configuration (eventbridge_schema_registry_cross_account_access, eventbridge_bus_cross_account_access)
+ # AWS DynamoDB Configuration (dynamodb_table_cross_account_access)
# Single account environment: No action required. The AWS account number will be automatically added by the checks.
# Multi account environment: Any additional trusted account number should be added as a space separated list, e.g.
# trusted_account_ids : ["123456789012", "098765432109", "678901234567"]
@@ -510,6 +513,9 @@ gcp:
# gcp.compute_instance_group_multiple_zones
# Minimum number of zones a MIG should span for high availability
mig_min_zones: 2
+ # gcp.compute_snapshot_not_outdated
+ # Maximum age in days for disk snapshots before they are considered outdated
+ max_snapshot_age_days: 90
# GCP Service Account and user-managed keys unused configuration
# gcp.iam_service_account_unused
#Β gcp.iam_sa_user_managed_key_unused
diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py
index 44e0078754..6a8c050cbb 100644
--- a/prowler/lib/check/models.py
+++ b/prowler/lib/check/models.py
@@ -779,7 +779,10 @@ class CheckReportCloudflare(Check_Report):
@property
def zone_name(self) -> str:
- """Zone name."""
+ """Zone name - for DNS records use zone_name attribute, for zones use name."""
+ zone_name = getattr(self._zone, "zone_name", None)
+ if zone_name:
+ return zone_name
return getattr(self._zone, "name", "")
@property
@@ -792,7 +795,10 @@ class CheckReportCloudflare(Check_Report):
@property
def region(self) -> str:
- """Cloudflare is a global service."""
+ """Return zone_name as region for zone-scoped resources, otherwise global."""
+ zone_name = getattr(self._zone, "zone_name", None)
+ if zone_name:
+ return zone_name
return "global"
diff --git a/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/__init__.py b/prowler/lib/timeline/__init__.py
similarity index 100%
rename from prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/__init__.py
rename to prowler/lib/timeline/__init__.py
diff --git a/prowler/lib/timeline/models.py b/prowler/lib/timeline/models.py
new file mode 100644
index 0000000000..cfd98284ba
--- /dev/null
+++ b/prowler/lib/timeline/models.py
@@ -0,0 +1,27 @@
+from datetime import datetime
+from typing import Any, Dict, Optional
+
+from pydantic.v1 import BaseModel
+
+
+class TimelineEvent(BaseModel):
+ """A timeline event representing a resource modification.
+
+ Provider-agnostic model that can be used by any timeline implementation
+ (AWS CloudTrail, Azure Activity Logs, GCP Audit Logs, etc.).
+ """
+
+ event_id: str
+ event_time: datetime
+ event_name: str
+ event_source: str
+ actor: str
+ actor_uid: Optional[str] = None
+ actor_type: Optional[str] = None
+ source_ip_address: Optional[str] = None
+ user_agent: Optional[str] = None
+ request_data: Optional[Dict[str, Any]] = None
+ response_data: Optional[Dict[str, Any]] = None
+ error_code: Optional[str] = None
+ error_message: Optional[str] = None
+ metadata: Optional[Dict[str, Any]] = None
diff --git a/prowler/lib/timeline/timeline.py b/prowler/lib/timeline/timeline.py
new file mode 100644
index 0000000000..926a90f8ca
--- /dev/null
+++ b/prowler/lib/timeline/timeline.py
@@ -0,0 +1,36 @@
+"""Abstract base class for timeline services."""
+
+from abc import ABC, abstractmethod
+from typing import Any, Dict, List, Optional
+
+
+class TimelineService(ABC):
+ """Abstract base class for provider-specific timeline implementations.
+
+ Subclasses should implement the get_resource_timeline method to query
+ their provider's audit/activity log service (e.g., AWS CloudTrail,
+ Azure Activity Logs, GCP Audit Logs).
+ """
+
+ @abstractmethod
+ def get_resource_timeline(
+ self,
+ region: Optional[str] = None,
+ resource_id: Optional[str] = None,
+ resource_uid: Optional[str] = None,
+ ) -> List[Dict[str, Any]]:
+ """Get timeline events for a resource.
+
+ Args:
+ region: Region/location where the resource exists. Implementations
+ may provide a sensible default for global/regionless resources.
+ resource_id: Provider-specific resource ID (e.g., bucket name, instance ID)
+ resource_uid: Provider-specific unique identifier (e.g., AWS ARN, Azure Resource ID)
+
+ Returns:
+ List of timeline event dictionaries
+
+ Raises:
+ ValueError: If neither resource_id nor resource_uid is provided
+ """
+ raise NotImplementedError
diff --git a/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/__init__.py b/prowler/providers/aws/lib/cloudtrail_timeline/__init__.py
similarity index 100%
rename from prowler/providers/cloudflare/services/zones/zones_hsts_enabled/__init__.py
rename to prowler/providers/aws/lib/cloudtrail_timeline/__init__.py
diff --git a/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py b/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py
new file mode 100644
index 0000000000..45dff270d0
--- /dev/null
+++ b/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py
@@ -0,0 +1,218 @@
+"""CloudTrail timeline service for AWS.
+
+Queries AWS CloudTrail to retrieve timeline events for resources,
+showing who performed actions and when.
+"""
+
+import json
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, List, Optional
+
+from botocore.exceptions import ClientError
+
+from prowler.lib.logger import logger
+from prowler.lib.timeline.models import TimelineEvent
+from prowler.lib.timeline.timeline import TimelineService
+
+
+class CloudTrailTimeline(TimelineService):
+ """AWS CloudTrail implementation of TimelineService.
+
+ Args:
+ session: boto3.Session for AWS API calls
+ lookback_days: Number of days to look back (default 90, max 90 for Event History)
+ max_results: Maximum number of events to return
+ write_events_only: If True, filter out read-only events (Describe*, Get*, List*, etc.)
+ """
+
+ MAX_LOOKBACK_DAYS = 90
+
+ DEFAULT_MAX_RESULTS = 50 # Default page size for CloudTrail queries
+
+ # Prefixes for read-only API operations that don't modify resources
+ READ_ONLY_PREFIXES = (
+ "Describe",
+ "Get",
+ "List",
+ "Head",
+ "Check",
+ "Lookup",
+ "Search",
+ "Scan",
+ "Query",
+ "BatchGet",
+ "Select",
+ )
+
+ def __init__(
+ self,
+ session,
+ lookback_days: int = 90,
+ max_results: Optional[int] = None,
+ write_events_only: bool = True,
+ ):
+ self._session = session
+ self._lookback_days = min(lookback_days, self.MAX_LOOKBACK_DAYS)
+ self._max_results = max_results or self.DEFAULT_MAX_RESULTS
+ self._write_events_only = write_events_only
+ self._clients: Dict[str, Any] = {}
+
+ DEFAULT_REGION = "us-east-1" # Default for global resources in commercial partition
+
+ def get_resource_timeline(
+ self,
+ region: Optional[str] = None,
+ resource_id: Optional[str] = None,
+ resource_uid: Optional[str] = None,
+ ) -> List[Dict[str, Any]]:
+ """Get CloudTrail timeline events for a resource.
+
+ Args:
+ region: AWS region to query. Defaults to us-east-1 for global resources
+ (IAM, S3, Route53, etc.) in the commercial partition. Caller
+ should provide the correct region for regional resources.
+ resource_id: AWS resource ID (e.g., sg-1234567890abcdef0)
+ resource_uid: AWS resource ARN (unique identifier)
+
+ Returns:
+ List of timeline event dictionaries
+
+ Raises:
+ ValueError: If neither resource_id nor resource_uid is provided
+ ClientError: If AWS API call fails
+ """
+ resource_identifier = resource_uid or resource_id
+ if not resource_identifier:
+ raise ValueError("Either resource_id or resource_uid must be provided")
+
+ region = region or self.DEFAULT_REGION
+
+ try:
+ raw_events = self._lookup_events(resource_identifier, region)
+
+ events = []
+ for raw_event in raw_events:
+ # Filter read-only events if write_events_only is enabled
+ if self._write_events_only:
+ event_name = raw_event.get("EventName", "")
+ if self._is_read_only_event(event_name):
+ continue
+
+ parsed = self._parse_event(raw_event)
+ if parsed:
+ events.append(parsed)
+
+ return events
+
+ except ClientError as e:
+ logger.error(
+ f"CloudTrail timeline error for {resource_identifier} in {region}: "
+ f"{e.response['Error']['Code']} - {e.response['Error']['Message']}"
+ )
+ raise
+ except Exception as e:
+ lineno = e.__traceback__.tb_lineno if e.__traceback__ else "?"
+ logger.error(
+ f"CloudTrail timeline unexpected error: "
+ f"{e.__class__.__name__}[{lineno}]: {e}"
+ )
+ return []
+
+ def _is_read_only_event(self, event_name: str) -> bool:
+ """Check if an event is a read-only operation."""
+ return event_name.startswith(self.READ_ONLY_PREFIXES)
+
+ def _get_client(self, region: str):
+ """Get or create a CloudTrail client for the specified region."""
+ if region not in self._clients:
+ self._clients[region] = self._session.client(
+ "cloudtrail", region_name=region
+ )
+ return self._clients[region]
+
+ def _lookup_events(
+ self, resource_identifier: str, region: str
+ ) -> List[Dict[str, Any]]:
+ """Query CloudTrail for events related to a specific resource.
+
+ Uses MaxResults to limit the number of events returned, preparing
+ for API-level pagination. Currently returns up to max_results events
+ from the first page only.
+ """
+ client = self._get_client(region)
+ start_time = datetime.now(timezone.utc) - timedelta(days=self._lookback_days)
+
+ # Use direct API call with MaxResults instead of paginator
+ # This limits CloudTrail to return only max_results events
+ response = client.lookup_events(
+ LookupAttributes=[
+ {"AttributeKey": "ResourceName", "AttributeValue": resource_identifier}
+ ],
+ StartTime=start_time,
+ MaxResults=self._max_results,
+ )
+
+ return response.get("Events", [])
+
+ def _parse_event(self, raw_event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Parse a raw CloudTrail event into a TimelineEvent dictionary."""
+ try:
+ cloud_trail_event = raw_event.get("CloudTrailEvent", "{}")
+ if isinstance(cloud_trail_event, str):
+ details = json.loads(cloud_trail_event)
+ else:
+ details = cloud_trail_event
+
+ user_identity = details.get("userIdentity", {})
+
+ event = TimelineEvent(
+ event_id=raw_event.get("EventId"),
+ event_time=raw_event["EventTime"],
+ event_name=raw_event.get("EventName", "Unknown"),
+ event_source=raw_event.get("EventSource", "Unknown"),
+ actor=self._extract_actor(user_identity),
+ actor_uid=user_identity.get("arn"),
+ actor_type=user_identity.get("type"),
+ source_ip_address=details.get("sourceIPAddress"),
+ user_agent=details.get("userAgent"),
+ request_data=details.get("requestParameters"),
+ response_data=details.get("responseElements"),
+ error_code=details.get("errorCode"),
+ error_message=details.get("errorMessage"),
+ )
+
+ return event.dict()
+
+ except Exception as e:
+ logger.warning(
+ f"CloudTrail timeline: failed to parse event: "
+ f"{e.__class__.__name__}: {e}"
+ )
+ return None
+
+ @staticmethod
+ def _extract_actor(user_identity: Dict[str, Any]) -> str:
+ """Extract a human-readable actor name from CloudTrail userIdentity."""
+ # Try ARN first - most reliable
+ if arn := user_identity.get("arn"):
+ if "/" in arn:
+ parts = arn.split("/")
+ # For assumed-role, return the role name (second-to-last part)
+ if "assumed-role" in arn and len(parts) >= 2:
+ return parts[-2]
+ return parts[-1]
+ return arn.split(":")[-1]
+
+ # Fall back to userName
+ if username := user_identity.get("userName"):
+ return username
+
+ # Fall back to principalId
+ if principal_id := user_identity.get("principalId"):
+ return principal_id
+
+ # For service-invoked actions
+ if invoking_service := user_identity.get("invokedBy"):
+ return invoking_service
+
+ return "Unknown"
diff --git a/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/__init__.py b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/__init__.py
similarity index 100%
rename from prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/__init__.py
rename to prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/__init__.py
diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.metadata.json b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.metadata.json
new file mode 100644
index 0000000000..f83824d81b
--- /dev/null
+++ b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.metadata.json
@@ -0,0 +1,40 @@
+{
+ "Provider": "aws",
+ "CheckID": "codebuild_project_webhook_filters_use_anchored_patterns",
+ "CheckTitle": "CodeBuild project webhook filters use anchored regex patterns",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices"
+ ],
+ "ServiceName": "codebuild",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "AwsCodeBuildProject",
+ "ResourceGroup": "devops",
+ "Description": "AWS CodeBuild webhook filters using `ACTOR_ACCOUNT_ID`, `HEAD_REF`, or `BASE_REF` have regex patterns anchored with `^` (start) and `$` (end) to enforce exact matching and prevent substring bypass attacks.",
+ "Risk": "Unanchored patterns expose CI/CD pipelines to **CodeBreach** attacks. Attackers can bypass `ACTOR_ACCOUNT_ID` filters by creating GitHub accounts with IDs containing trusted values as substrings. **Confidentiality**: Credentials leaked via build logs. **Integrity**: Malicious code injected into builds. **Availability**: Resource exhaustion through unauthorized builds.",
+ "RelatedUrl": "",
+ "Remediation": {
+ "Code": {
+ "CLI": "aws codebuild update-webhook --project-name --filter-groups '[[{\"type\":\"ACTOR_ACCOUNT_ID\",\"pattern\":\"^123456$|^234567$\"}]]'",
+ "NativeIaC": "AWSTemplateFormatVersion: '2010-09-09'\nResources:\n CodeBuildWebhook:\n Type: AWS::CodeBuild::Project\n Properties:\n Triggers:\n Webhook: true\n FilterGroups:\n - - Type: ACTOR_ACCOUNT_ID\n Pattern: '^123456$|^234567$' # Anchored pattern",
+ "Other": "1. Open AWS Console and navigate to CodeBuild. 2. Select the project with webhook filters. 3. Click Edit and go to Primary source webhook events. 4. For each filter using ACTOR_ACCOUNT_ID, HEAD_REF, or BASE_REF, update patterns to include ^ at start and $ at end (e.g., change '123456|234567' to '^123456$|^234567$'). 5. Save changes.",
+ "Terraform": "resource \"aws_codebuild_webhook\" \"example\" {\n project_name = aws_codebuild_project.example.name\n filter_group {\n filter {\n type = \"ACTOR_ACCOUNT_ID\"\n pattern = \"^123456$|^234567$\" # Anchored pattern\n }\n }\n}"
+ },
+ "Recommendation": {
+ "Text": "Anchor all webhook filter patterns with `^` (start) and `$` (end) to enforce exact matching. For multiple values use: `^value1$|^value2$`. This prevents attackers from bypassing filters using substring matches.",
+ "Url": "https://hub.prowler.com/check/codebuild_project_webhook_filters_use_anchored_patterns"
+ }
+ },
+ "Categories": [
+ "software-supply-chain",
+ "ci-cd"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "This check targets the CodeBreach vulnerability disclosed by Wiz Research. The vulnerability allows attackers to bypass ACTOR_ACCOUNT_ID filters by creating GitHub accounts with IDs that contain trusted IDs as substrings.",
+ "AdditionalURLs": [
+ "https://www.wiz.io/blog/wiz-research-codebreach-vulnerability-aws-codebuild",
+ "https://docs.aws.amazon.com/codebuild/latest/userguide/github-webhook.html"
+ ]
+}
diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.py b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.py
new file mode 100644
index 0000000000..231e392687
--- /dev/null
+++ b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.py
@@ -0,0 +1,58 @@
+from typing import List
+
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.codebuild.codebuild_client import codebuild_client
+
+HIGH_RISK_FILTER_TYPES = {"ACTOR_ACCOUNT_ID", "HEAD_REF", "BASE_REF"}
+
+
+def is_pattern_anchored(pattern: str) -> bool:
+ """Check if each alternative in a pipe-separated pattern is anchored with ^ and $."""
+ if not pattern:
+ return True
+
+ for alt in pattern.split("|"):
+ alt = alt.strip()
+ if alt and not (alt.startswith("^") and alt.endswith("$")):
+ return False
+ return True
+
+
+class codebuild_project_webhook_filters_use_anchored_patterns(Check):
+ def execute(self) -> List[Check_Report_AWS]:
+ findings = []
+
+ for project in codebuild_client.projects.values():
+ report = Check_Report_AWS(metadata=self.metadata(), resource=project)
+ report.status = "PASS"
+ report.status_extended = (
+ f"CodeBuild project {project.name} has no webhook configured or all "
+ "webhook filter patterns are properly anchored."
+ )
+
+ if not project.webhook or not project.webhook.filter_groups:
+ findings.append(report)
+ continue
+
+ unanchored_filters = []
+ for filter_group in project.webhook.filter_groups:
+ for webhook_filter in filter_group.filters:
+ if webhook_filter.type in HIGH_RISK_FILTER_TYPES:
+ if not is_pattern_anchored(webhook_filter.pattern):
+ unanchored_filters.append(
+ f"{webhook_filter.type}: '{webhook_filter.pattern}'"
+ )
+
+ if unanchored_filters:
+ report.status = "FAIL"
+ filters_str = ", ".join(unanchored_filters[:3])
+ if len(unanchored_filters) > 3:
+ filters_str += f" and {len(unanchored_filters) - 3} more"
+ report.status_extended = (
+ f"CodeBuild project {project.name} has webhook filters with "
+ f"unanchored patterns that could allow bypass attacks: {filters_str}."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/aws/services/codebuild/codebuild_service.py b/prowler/providers/aws/services/codebuild/codebuild_service.py
index 475f578e94..361002aa65 100644
--- a/prowler/providers/aws/services/codebuild/codebuild_service.py
+++ b/prowler/providers/aws/services/codebuild/codebuild_service.py
@@ -122,6 +122,29 @@ class Codebuild(AWSService):
project.tags = project_info.get("tags", [])
project.service_role_arn = project_info.get("serviceRole", "")
project.project_visibility = project_info.get("projectVisibility", "")
+
+ # Extract webhook configuration
+ webhook_data = project_info.get("webhook")
+ if webhook_data:
+ filter_groups = []
+ for fg in webhook_data.get("filterGroups", []):
+ filters = []
+ for f in fg:
+ filters.append(
+ WebhookFilter(
+ type=f.get("type", ""),
+ pattern=f.get("pattern", ""),
+ exclude_matched_pattern=f.get(
+ "excludeMatchedPattern", False
+ ),
+ )
+ )
+ filter_groups.append(WebhookFilterGroup(filters=filters))
+
+ project.webhook = Webhook(
+ filter_groups=filter_groups,
+ branch_filter=webhook_data.get("branchFilter"),
+ )
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -209,6 +232,27 @@ class CloudWatchLogs(BaseModel):
stream_name: str
+class WebhookFilter(BaseModel):
+ """Represents a single filter in a webhook filter group."""
+
+ type: str # ACTOR_ACCOUNT_ID, HEAD_REF, BASE_REF, EVENT, etc.
+ pattern: str
+ exclude_matched_pattern: bool = False
+
+
+class WebhookFilterGroup(BaseModel):
+ """Represents a group of filters (AND logic within group)."""
+
+ filters: List[WebhookFilter] = []
+
+
+class Webhook(BaseModel):
+ """Represents the webhook configuration for a CodeBuild project."""
+
+ filter_groups: List[WebhookFilterGroup] = []
+ branch_filter: Optional[str] = None
+
+
class Project(BaseModel):
name: str
arn: str
@@ -224,6 +268,7 @@ class Project(BaseModel):
cloudwatch_logs: Optional[CloudWatchLogs]
tags: Optional[list]
project_visibility: Optional[str] = None
+ webhook: Optional[Webhook] = None
class ExportConfig(BaseModel):
diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json
index a7bcb9456e..13a434f4c8 100644
--- a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json
+++ b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json
@@ -38,5 +38,5 @@
],
"DependsOn": [],
"RelatedTo": [],
- "Notes": ""
+ "Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding."
}
diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py
index e3c1e817ca..208c2d35dd 100644
--- a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py
+++ b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py
@@ -6,6 +6,9 @@ from prowler.providers.aws.services.iam.lib.policy import is_policy_public
class dynamodb_table_cross_account_access(Check):
def execute(self):
findings = []
+ trusted_account_ids = dynamodb_client.audit_config.get(
+ "trusted_account_ids", []
+ )
for table in dynamodb_client.tables.values():
if table.policy is None:
continue
@@ -20,6 +23,7 @@ class dynamodb_table_cross_account_access(Check):
table.policy,
dynamodb_client.audited_account,
is_cross_account_allowed=False,
+ trusted_account_ids=trusted_account_ids,
):
report.status = "FAIL"
report.status_extended = f"DynamoDB table {table.name} has a resource-based policy allowing cross account access."
diff --git a/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.metadata.json b/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.metadata.json
index d01f57ab62..d54d112516 100644
--- a/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_ami_public",
- "CheckTitle": "Ensure there are no EC2 AMIs set as Public.",
+ "CheckTitle": "EC2 AMI owned by the account is not public",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "ami",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "Other",
"ResourceGroup": "compute",
- "Description": "Ensure there are no EC2 AMIs set as Public.",
- "Risk": "When your AMIs are publicly accessible, they are available in the Community AMIs where everyone with an AWS account can use them to launch EC2 instances. Your AMIs could contain snapshots of your applications (including their data), therefore exposing your snapshots in this manner is not advised.",
+ "Description": "**EC2 AMIs owned by the account** are evaluated for **public visibility** via their launch permissions. Images shared with all accounts (`Group=all`) are treated as publicly accessible.",
+ "Risk": "Public AMIs expose image contents to any AWS account, undermining **confidentiality** and **integrity**:\n- Leakage of embedded secrets, configs, or data from referenced snapshots\n- Adversaries can fingerprint your stack, aiding targeted exploits or repackaging for supply chain abuse",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/cancel-sharing-an-AMI.html",
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sharingamis-explicit.html",
+ "https://docs.aws.amazon.com/cli/latest/reference/ec2/modify-image-attribute.html",
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sharingamis-intro.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "aws ec2 modify-image-attribute --region --image-id --launch-permission {\"Remove\":[{\"Group\":\"all\"}]}",
+ "CLI": "aws ec2 modify-image-attribute --image-id --launch-permission \"Remove=[{Group=all}]\"",
"NativeIaC": "",
- "Other": "https://docs.prowler.com/checks/aws/public-policies/public_8",
+ "Other": "1. Open the Amazon EC2 console and go to AMIs\n2. Select the AMI with Visibility = Public\n3. Click Actions > Edit AMI permissions\n4. Under AMI availability, select Private\n5. Click Save changes",
"Terraform": ""
},
"Recommendation": {
- "Text": "We recommend your EC2 AMIs are not publicly accessible, or generally available in the Community AMIs.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/cancel-sharing-an-AMI.html"
+ "Text": "Keep AMIs **private** and enforce **least privilege** launch permissions. Share only with specific accounts and review access routinely. Enable **block public access for AMIs**, sanitize images to remove secrets, encrypt backing snapshots, and apply lifecycle governance to retire outdated images.",
+ "Url": "https://hub.prowler.com/check/ec2_ami_public"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_client_vpn_endpoint_connection_logging_enabled/ec2_client_vpn_endpoint_connection_logging_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_client_vpn_endpoint_connection_logging_enabled/ec2_client_vpn_endpoint_connection_logging_enabled.metadata.json
index 1f78c2ffb1..8a9ad91e20 100644
--- a/prowler/providers/aws/services/ec2/ec2_client_vpn_endpoint_connection_logging_enabled/ec2_client_vpn_endpoint_connection_logging_enabled.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_client_vpn_endpoint_connection_logging_enabled/ec2_client_vpn_endpoint_connection_logging_enabled.metadata.json
@@ -1,30 +1,41 @@
{
"Provider": "aws",
"CheckID": "ec2_client_vpn_endpoint_connection_logging_enabled",
- "CheckTitle": "EC2 Client VPN endpoints should have client connection logging enabled.",
- "CheckType": [],
+ "CheckTitle": "EC2 Client VPN endpoint has client connection logging enabled",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)"
+ ],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "AwsEc2ClientVpnEndpoint",
"ResourceGroup": "network",
- "Description": "This control checks whether an AWS Client VPN endpoint has client connection logging enabled. The control fails if the endpoint doesn't have client connection logging enabled.",
- "Risk": "Client VPN endpoints allow remote clients to securely connect to resources in a Virtual Private Cloud (VPC) in AWS. Connection logs allow you to track user activity on the VPN endpoint and provides visibility.",
- "RelatedUrl": "https://docs.aws.amazon.com/vpn/latest/clientvpn-admin/what-is.html",
+ "Description": "**AWS Client VPN endpoints** are evaluated for **client connection logging** that records client connect/disconnect events to CloudWatch Logs. The evaluation detects endpoints where this logging is disabled.",
+ "Risk": "Without **Client VPN connection logs**, remote access lacks an **audit trail**, reducing detection and accountability.\n- Stolen credentials can be used unnoticed\n- Lateral movement and data exfiltration persist\nImpacts **confidentiality** and **integrity**; delayed investigation can degrade **availability**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpn/latest/clientvpn-admin/what-is.html",
+ "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-51",
+ "https://docs.aws.amazon.com/config/latest/developerguide/ec2-client-vpn-connection-log-enabled.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-51",
- "Terraform": ""
+ "CLI": "aws ec2 modify-client-vpn-endpoint --client-vpn-endpoint-id --connection-log-options Enabled=true,CloudWatchLogGroup=",
+ "NativeIaC": "```yaml\n# CloudFormation: enable connection logging on a Client VPN endpoint\nResources:\n :\n Type: AWS::EC2::ClientVpnEndpoint\n Properties:\n ClientCidrBlock: 10.0.0.0/22\n ServerCertificateArn: arn:aws:acm:::certificate/\n AuthenticationOptions:\n - Type: certificate-authentication\n MutualAuthentication:\n ClientRootCertificateChainArn: arn:aws:acm:::certificate/\n ConnectionLogOptions: # CRITICAL: enables client connection logging\n Enabled: true # CRITICAL: turns on logging\n CloudWatchLogGroup: # CRITICAL: destination log group\n```",
+ "Other": "1. Open the Amazon VPC console and go to Client VPN Endpoints\n2. Select the endpoint and choose Actions > Modify client VPN endpoint\n3. Under Connection logging, check Enable\n4. For CloudWatch log group, select an existing log group\n5. Click Save changes",
+ "Terraform": "```hcl\n# Terraform: enable connection logging on a Client VPN endpoint\nresource \"aws_ec2_client_vpn_endpoint\" \"\" {\n server_certificate_arn = \"arn:aws:acm:::certificate/\"\n client_cidr_block = \"10.0.0.0/22\"\n\n authentication_options {\n type = \"certificate-authentication\"\n root_certificate_chain_arn = \"arn:aws:acm:::certificate/\"\n }\n\n connection_log_options { # CRITICAL: enables client connection logging\n enabled = true # CRITICAL: turns on logging\n cloudwatch_log_group = \"\" # CRITICAL: destination log group\n }\n}\n```"
},
"Recommendation": {
- "Text": "To enable connection logging, see Enable connection logging for an existing Client VPN endpoint in the AWS Client VPN Administrator Guide.",
- "Url": "https://docs.aws.amazon.com/config/latest/developerguide/ec2-client-vpn-connection-log-enabled.html"
+ "Text": "Enable **client connection logging** on all Client VPN endpoints and send events to a centralized log group.\n- Enforce least privilege on log access\n- Define retention and immutability\n- Integrate with monitoring/alerts\n- Separate VPN operations from log administration\n- Review anomalous login patterns",
+ "Url": "https://hub.prowler.com/check/ec2_client_vpn_endpoint_connection_logging_enabled"
}
},
- "Categories": [],
+ "Categories": [
+ "logging"
+ ],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_default_encryption/ec2_ebs_default_encryption.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_default_encryption/ec2_ebs_default_encryption.metadata.json
index 8e61062980..691af1b376 100644
--- a/prowler/providers/aws/services/ec2/ec2_ebs_default_encryption/ec2_ebs_default_encryption.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_ebs_default_encryption/ec2_ebs_default_encryption.metadata.json
@@ -1,29 +1,37 @@
{
"Provider": "aws",
"CheckID": "ec2_ebs_default_encryption",
- "CheckTitle": "Check if EBS Default Encryption is activated.",
+ "CheckTitle": "EBS default encryption is enabled",
"CheckType": [
- "Data Protection"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "ebs",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
- "Severity": "medium",
- "ResourceType": "Other",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "AwsEc2Volume",
"ResourceGroup": "compute",
- "Description": "Check if EBS Default Encryption is activated.",
- "Risk": "If not enabled sensitive information at rest is not protected.",
+ "Description": "**EBS** uses `encryption by default` at the account and region level, ensuring new volumes, snapshots, and AMI-backed volumes are automatically encrypted with a chosen **KMS key**",
+ "Risk": "Without `encryption by default`, data on new **EBS volumes** and **snapshots** may be stored in plaintext. A compromised account or mis-shared snapshot can expose disk contents, enabling data exfiltration, offline analysis, and loss of **confidentiality**.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://aws.amazon.com/premiumsupport/knowledge-center/ebs-automatic-encryption/",
+ "https://docs.aws.amazon.com/ebs/latest/userguide/encryption-by-default.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/configure-default-encryption.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "aws ec2 enable-ebs-encryption-by-default",
- "NativeIaC": "",
- "Other": "https://docs.prowler.com/checks/aws/general-policies/ensure-ebs-default-encryption-is-enabled#aws-console",
- "Terraform": "https://docs.prowler.com/checks/aws/general-policies/ensure-ebs-default-encryption-is-enabled#terraform"
+ "CLI": "aws ec2 enable-ebs-encryption-by-default --region ",
+ "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::EC2::EBSEncryptionByDefault\n Properties:\n Enabled: true # Critical: turns on default EBS encryption in this region\n```",
+ "Other": "1. In the AWS console, switch to the affected Region\n2. Go to EC2 > Settings (or Account attributes) > EBS encryption\n3. Click Enable default encryption and Save",
+ "Terraform": "```hcl\nresource \"aws_ebs_encryption_by_default\" \"\" {\n enabled = true # Critical: enables default EBS encryption in this region\n}\n```"
},
"Recommendation": {
- "Text": "Enable Encryption. Use a CMK where possible. It will provide additional management and privacy benefits.",
- "Url": "https://aws.amazon.com/premiumsupport/knowledge-center/ebs-automatic-encryption/"
+ "Text": "Enable `EBS encryption by default` in every region and select a **customer-managed KMS key**. Apply **least privilege** to key use, rotate keys, and monitor access. Enforce encrypted volume creation with organizational guardrails and secure templates as **defense in depth**.",
+ "Url": "https://hub.prowler.com/check/ec2_ebs_default_encryption"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_public_snapshot/ec2_ebs_public_snapshot.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_public_snapshot/ec2_ebs_public_snapshot.metadata.json
index b8e84aa4bb..cc602f1e7f 100644
--- a/prowler/providers/aws/services/ec2/ec2_ebs_public_snapshot/ec2_ebs_public_snapshot.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_ebs_public_snapshot/ec2_ebs_public_snapshot.metadata.json
@@ -1,29 +1,35 @@
{
"Provider": "aws",
"CheckID": "ec2_ebs_public_snapshot",
- "CheckTitle": "Ensure there are no EBS Snapshots set as Public.",
+ "CheckTitle": "EBS snapshot is not public",
"CheckType": [
- "Data Protection"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "snapshot",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
- "ResourceType": "Other",
+ "ResourceType": "AwsEc2Volume",
"ResourceGroup": "compute",
- "Description": "Ensure there are no EBS Snapshots set as Public.",
- "Risk": "When you share a snapshot, you are giving others access to all of the data on the snapshot. Share snapshots only with people with whom you want to share all of your snapshot data.",
+ "Description": "**EBS snapshots** with **public sharing** permissions (accessible by all AWS accounts) are identified, as opposed to snapshots shared privately with specific accounts.",
+ "Risk": "Public snapshots expose full volume contents, harming **confidentiality**. Any account can create a volume from the snapshot to read files, secrets, or database data, enabling **data exfiltration**, broad reconnaissance, and facilitating **lateral movement**.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-modifying-snapshot-permissions.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/public-snapshots.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "aws ec2 modify-snapshot-attribute --region --snapshot-id --attribute createVolumePermission --operation remove --user-ids all",
+ "CLI": "aws ec2 modify-snapshot-attribute --snapshot-id --attribute createVolumePermission --operation-type remove --group-names all",
"NativeIaC": "",
- "Other": "https://docs.prowler.com/checks/aws/public-policies/public_7",
+ "Other": "1. Open the AWS Management Console and go to EC2\n2. In the left menu, select Snapshots\n3. Select the snapshot \n4. Click Actions > Modify permissions\n5. Choose Private (remove Public/all if present)\n6. Click Save changes",
"Terraform": ""
},
"Recommendation": {
- "Text": "Ensure the snapshot should be shared.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-modifying-snapshot-permissions.html"
+ "Text": "Keep snapshots **private** and share only with specific accounts under **least privilege**. Enable `Block public access for Amazon EBS snapshots` regionally. Prefer **CMEK encryption** and avoid sharing keys broadly. Regularly review sharing permissions and monitor snapshot usage.",
+ "Url": "https://hub.prowler.com/check/ec2_ebs_public_snapshot"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_snapshot_account_block_public_access/ec2_ebs_snapshot_account_block_public_access.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_snapshot_account_block_public_access/ec2_ebs_snapshot_account_block_public_access.metadata.json
index a896e1386f..88f1dfa7e8 100644
--- a/prowler/providers/aws/services/ec2/ec2_ebs_snapshot_account_block_public_access/ec2_ebs_snapshot_account_block_public_access.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_ebs_snapshot_account_block_public_access/ec2_ebs_snapshot_account_block_public_access.metadata.json
@@ -1,29 +1,35 @@
{
"Provider": "aws",
"CheckID": "ec2_ebs_snapshot_account_block_public_access",
- "CheckTitle": "Ensure that public access to EBS snapshots is disabled",
+ "CheckTitle": "All EBS snapshots have public access blocked",
"CheckType": [
- "Data Protection"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "snapshot",
- "ResourceIdTemplate": "arn:partition:service:region:account-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "high",
- "ResourceType": "AwsAccount",
- "ResourceGroup": "governance",
- "Description": "EBS snapshots can be shared with other AWS accounts or made public. By default, EBS snapshots are private and only the AWS account that created the snapshot can access it. If an EBS snapshot is shared with another AWS account or made public, the data in the snapshot can be accessed by the other account or by anyone on the internet. Ensure that public access to EBS snapshots is disabled.",
- "Risk": "If public access to EBS snapshots is enabled, the data in the snapshot can be accessed by anyone on the internet.",
- "RelatedUrl": "https://docs.aws.amazon.com/ebs/latest/userguide/block-public-access-snapshots-work.html#block-public-access-snapshots-enable",
+ "ResourceType": "AwsEc2Volume",
+ "ResourceGroup": "compute",
+ "Description": "**EBS snapshots** account/Region configuration for **Block Public Access** is assessed to see whether public sharing is fully blocked (`block-all-sharing`) versus only new sharing (`block-new-sharing`) or unblocked. The state indicates if any snapshot can be publicly shared.",
+ "Risk": "Without `block-all-sharing`, previously public snapshots can remain accessible, exposing raw disk data.\n\nImpacts:\n- Loss of **confidentiality** (PII, keys, configs)\n- Unauthorized cloning enabling **lateral movement**\n- Cross-account copies create **irreversible data leakage**",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/ebs/latest/userguide/block-public-access-snapshots-work.html#block-public-access-snapshots-enable",
+ "https://docs.aws.amazon.com/ebs/latest/userguide/block-public-access-snapshots.html"
+ ],
"Remediation": {
"Code": {
"CLI": "aws ec2 enable-snapshot-block-public-access --state block-all-sharing",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::EC2::SnapshotBlockPublicAccess\n Properties:\n State: block-all-sharing # CRITICAL: Blocks all public sharing of EBS snapshots in this Region to pass the check\n```",
+ "Other": "1. In the AWS console, select the target Region in the top-right.\n2. Go to EC2 > Snapshots.\n3. Click Settings > Block public access for snapshots.\n4. Select Block all sharing.\n5. Click Save changes.",
+ "Terraform": "```hcl\nresource \"aws_ebs_snapshot_block_public_access\" \"\" {\n state = \"block-all-sharing\" # CRITICAL: Blocks all public sharing of EBS snapshots in this Region\n}\n```"
},
"Recommendation": {
- "Text": "Use the following procedures to configure and monitor block public access for snapshots.",
- "Url": "https://docs.aws.amazon.com/ebs/latest/userguide/block-public-access-snapshots-work.html#block-public-access-snapshots-enable"
+ "Text": "Set **Block Public Access** for EBS snapshots to `block-all-sharing` in all active Regions.\n\nApply **least privilege** and guardrails (SCPs) to prevent changes. Regularly inventory snapshots, remove public sharing, and use segregated accounts with strict reviews for any necessary external sharing.",
+ "Url": "https://hub.prowler.com/check/ec2_ebs_snapshot_account_block_public_access"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_snapshots_encrypted/ec2_ebs_snapshots_encrypted.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_snapshots_encrypted/ec2_ebs_snapshots_encrypted.metadata.json
index f98e613033..606e7c5eef 100644
--- a/prowler/providers/aws/services/ec2/ec2_ebs_snapshots_encrypted/ec2_ebs_snapshots_encrypted.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_ebs_snapshots_encrypted/ec2_ebs_snapshots_encrypted.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_ebs_snapshots_encrypted",
- "CheckTitle": "Check if EBS snapshots are encrypted.",
+ "CheckTitle": "EBS snapshot is encrypted",
"CheckType": [
- "Data Protection"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "snapshot",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
- "Severity": "medium",
- "ResourceType": "Other",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "AwsEc2Volume",
"ResourceGroup": "compute",
- "Description": "Check if EBS snapshots are encrypted.",
- "Risk": "Data encryption at rest prevents data visibility in the event of its unauthorized access or theft.",
+ "Description": "**EBS snapshots** are evaluated for **encryption at rest** with AWS KMS. The finding identifies snapshots where encryption is not enabled.",
+ "Risk": "Unencrypted snapshots expose complete disk images to anyone with snapshot access or if mis-shared. Attackers can exfiltrate data, harvest credentials, and clone volumes for offline analysis, compromising **confidentiality** and enabling **lateral movement**.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#encryption-by-default",
+ "https://docs.aws.amazon.com/ebs/latest/userguide/ebs-encryption.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/snapshot-encrypted.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "aws ec2 --region enable-ebs-encryption-by-default",
- "NativeIaC": "https://docs.prowler.com/checks/aws/general-policies/general_3-encrypt-ebs-volume#cloudformation",
- "Other": "https://docs.prowler.com/checks/aws/general-policies/general_3-encrypt-ebs-volume#aws-console",
- "Terraform": "https://docs.prowler.com/checks/aws/general-policies/general_3-encrypt-ebs-volume#terraform"
+ "CLI": "aws ec2 copy-snapshot --source-region --source-snapshot-id --encrypted --description \"Encrypted copy of \"",
+ "NativeIaC": "",
+ "Other": "1. In the AWS Console, go to EC2 > Snapshots and select the unencrypted snapshot\n2. Click Actions > Copy snapshot\n3. Check Encrypt this snapshot (leave the default KMS key unless a specific key is required)\n4. Click Copy snapshot and wait for the new encrypted snapshot to be available\n5. Select the original unencrypted snapshot > Actions > Delete snapshot > Delete",
+ "Terraform": "```hcl\nresource \"aws_ebs_snapshot_copy\" \"\" {\n source_snapshot_id = \"\"\n source_region = \"\"\n encrypted = true # Critical: creates an encrypted copy of the snapshot to remediate the finding\n}\n```"
},
"Recommendation": {
- "Text": "Encrypt all EBS Snapshot and Enable Encryption by default. You can configure your AWS account to enforce the encryption of the new EBS volumes and snapshot copies that you create. For example, Amazon EBS encrypts the EBS volumes created when you launch an instance and the snapshots that you copy from an unencrypted snapshot.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#encryption-by-default"
+ "Text": "Encrypt all **EBS snapshots** and enable `encryption by default` to prevent unencrypted creations and copies. Use **customer-managed KMS keys** for control and rotation, restrict snapshot sharing, and enforce **least privilege** on snapshot and key permissions as **defense in depth**.",
+ "Url": "https://hub.prowler.com/check/ec2_ebs_snapshots_encrypted"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_volume_encryption/ec2_ebs_volume_encryption.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_volume_encryption/ec2_ebs_volume_encryption.metadata.json
index 79356a2544..1b4033c4b9 100644
--- a/prowler/providers/aws/services/ec2/ec2_ebs_volume_encryption/ec2_ebs_volume_encryption.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_ebs_volume_encryption/ec2_ebs_volume_encryption.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_ebs_volume_encryption",
- "CheckTitle": "Ensure there are no EBS Volumes unencrypted.",
+ "CheckTitle": "EBS volume is encrypted",
"CheckType": [
- "Data Protection"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "volume",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
- "Severity": "medium",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
"ResourceType": "AwsEc2Volume",
- "ResourceGroup": "storage",
- "Description": "Ensure there are no EBS Volumes unencrypted.",
- "Risk": "Data encryption at rest prevents data visibility in the event of its unauthorized access or theft.",
+ "ResourceGroup": "compute",
+ "Description": "**EBS volumes** are assessed for **encryption at rest** using **AWS KMS**.\n\nThe finding identifies volumes whose `encrypted` state is disabled, meaning data is stored unencrypted on block storage.",
+ "Risk": "Unencrypted volumes or snapshots can be copied, shared, or recovered and reveal raw data, undermining **confidentiality**.\n\nAdversaries with host or account access can read disks offline, harvest secrets, or alter system images, affecting **integrity** and enabling **lateral movement**.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/ebs-encrypted.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 create-snapshot --volume-id --description \"Snapshot for encryption\" && aws ec2 copy-snapshot --source-region --source-snapshot-id --encrypted --description \"Encrypted snapshot\" && aws ec2 create-volume --snapshot-id --availability-zone --encrypted",
+ "NativeIaC": "```yaml\n# CloudFormation: Encrypted EBS volume\nResources:\n :\n Type: AWS::EC2::Volume\n Properties:\n AvailabilityZone: \n Size: 1\n Encrypted: true # CRITICAL: enables EBS encryption so the volume is created encrypted\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Volumes and select the unencrypted volume\n2. Choose Actions > Create snapshot and wait for it to complete\n3. Open the snapshot, click Actions > Create volume, select the same Availability Zone, and check Encrypted, then create\n4. Stop the instance using the old volume\n5. Detach the old (unencrypted) volume\n6. Attach the new encrypted volume to the instance using the same device name\n7. Start the instance\n8. Verify the new volume shows Encrypted = Yes",
+ "Terraform": "```hcl\n# Encrypted EBS volume\nresource \"aws_ebs_volume\" \"\" {\n availability_zone = \"\"\n size = 1\n encrypted = true # CRITICAL: ensures the volume is created encrypted\n}\n```"
},
"Recommendation": {
- "Text": "Encrypt all EBS volumes and Enable Encryption by default You can configure your AWS account to enforce the encryption of the new EBS volumes and snapshot copies that you create. For example, Amazon EBS encrypts the EBS volumes created when you launch an instance and the snapshots that you copy from an unencrypted snapshot.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html"
+ "Text": "Encrypt all EBS volumes and enable `encryption by default` for new volumes and snapshot copies.\n\nApply **least privilege** to KMS keys, restrict snapshot sharing, and enforce **defense in depth** with policies and templates that prevent creation of unencrypted storage.",
+ "Url": "https://hub.prowler.com/check/ec2_ebs_volume_encryption"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_volume_protected_by_backup_plan/ec2_ebs_volume_protected_by_backup_plan.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_volume_protected_by_backup_plan/ec2_ebs_volume_protected_by_backup_plan.metadata.json
index 7fe1bfa832..46b406cf1a 100644
--- a/prowler/providers/aws/services/ec2/ec2_ebs_volume_protected_by_backup_plan/ec2_ebs_volume_protected_by_backup_plan.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_ebs_volume_protected_by_backup_plan/ec2_ebs_volume_protected_by_backup_plan.metadata.json
@@ -1,33 +1,40 @@
{
"Provider": "aws",
"CheckID": "ec2_ebs_volume_protected_by_backup_plan",
- "CheckTitle": "Amazon EBS volumes should be protected by a backup plan.",
+ "CheckTitle": "EBS volume is protected by a backup plan",
"CheckType": [
- "Software and Configuration Checks, AWS Security Best Practices"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Effects/Data Destruction"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:volume/volume-id",
- "Severity": "low",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
"ResourceType": "AwsEc2Volume",
- "ResourceGroup": "storage",
- "Description": "Evaluates if an Amazon EBS volume in in-use state is covered by a backup plan. The check fails if an EBS volume isn't covered by a backup plan. If you set the backupVaultLockCheck parameter equal to true, the control passes only if the EBS volume is backed up in an AWS Backup locked vault.",
- "Risk": "Without backup coverage, Amazon EBS volumes are vulnerable to data loss or deletion, reducing the resilience of your systems and making recovery from incidents more difficult.",
- "RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/ebs-resources-protected-by-backup-plan.html",
+ "ResourceGroup": "compute",
+ "Description": "**EBS volumes** are evaluated for coverage by an **AWS Backup plan**, whether explicitly targeted or included via broad resource selection, confirming scheduled, policy-driven backups exist for the volume.",
+ "Risk": "Absent backup coverage, volumes face **data loss**, weakened **integrity**, and reduced **availability**. Deletion or corruption-whether accidental or malicious-can leave no recovery path, causing prolonged outages, failed point-in-time restoration, unmet retention needs, and harder incident response.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.amazonaws.cn/en_us/aws-backup/latest/devguide/vault-lock.html",
+ "https://aws.amazon.com/blogs/storage/protecting-your-critical-amazon-ebs-volumes-using-aws-backup/",
+ "https://docs.aws.amazon.com/config/latest/developerguide/ebs-resources-protected-by-backup-plan.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-28",
- "Terraform": ""
+ "CLI": "aws backup create-backup-selection --backup-plan-id --backup-selection '{\"SelectionName\":\"\",\"IamRoleArn\":\"arn:aws:iam:::role/service-role/AWSBackupDefaultServiceRole\",\"Resources\":[\"arn:aws:ec2:*:*:volume/*\"]}'",
+ "NativeIaC": "```yaml\n# CloudFormation: protect all EBS volumes by assigning them to a backup plan\nResources:\n BackupPlan:\n Type: AWS::Backup::BackupPlan\n Properties:\n BackupPlan:\n BackupPlanName: \n Rules:\n - RuleName: \n TargetBackupVault: Default\n\n BackupSelection:\n Type: AWS::Backup::BackupSelection\n Properties:\n BackupPlanId: !Ref BackupPlan\n BackupSelection:\n SelectionName: \n IamRoleArn: arn:aws:iam:::role/service-role/AWSBackupDefaultServiceRole\n Resources:\n - arn:aws:ec2:*:*:volume/* # Critical: wildcard includes all EBS volumes so they are covered by the plan\n```",
+ "Other": "1. In the AWS Backup console, go to Backup plans and click Create backup plan\n2. Choose Start with a template (any), keep the Default vault, and create the plan\n3. Open the plan and click Assign resources\n4. Set Selection name and choose IAM role AWSBackupDefaultServiceRole\n5. Under Assign resources, choose Include specific resource types and select EBS\n6. For Resources, select all EBS volumes (or the specific volumes to protect) and click Assign resources",
+ "Terraform": "```hcl\n# Minimal AWS Backup plan protecting all EBS volumes\nresource \"aws_backup_plan\" \"\" {\n name = \"\"\n\n rule {\n rule_name = \"\"\n target_vault_name = \"Default\"\n }\n}\n\nresource \"aws_backup_selection\" \"\" {\n name = \"\"\n plan_id = aws_backup_plan..id\n iam_role_arn = \"arn:aws:iam:::role/service-role/AWSBackupDefaultServiceRole\"\n\n resources = [\n \"arn:aws:ec2:*:*:volume/*\" # Critical: selects all EBS volumes to satisfy the check\n ]\n}\n```"
},
"Recommendation": {
- "Text": "Ensure that all in-use Amazon EBS volumes are included in a backup plan, and consider using AWS Backup Vault Lock for additional protection.",
- "Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/assigning-resources.html"
+ "Text": "Include all critical EBS volumes in standardized **AWS Backup** plans aligned to your `RPO`/`RTO`. Use tags for automatic assignment, enable cross-Region/account copies, apply **Vault Lock** for WORM retention, encrypt with KMS, enforce least-privilege access, and regularly test restores to verify integrity.",
+ "Url": "https://hub.prowler.com/check/ec2_ebs_volume_protected_by_backup_plan"
}
},
"Categories": [
- "redundancy"
+ "resilience"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_volume_snapshots_exists/ec2_ebs_volume_snapshots_exists.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_volume_snapshots_exists/ec2_ebs_volume_snapshots_exists.metadata.json
index 338a977af9..d14225a216 100644
--- a/prowler/providers/aws/services/ec2/ec2_ebs_volume_snapshots_exists/ec2_ebs_volume_snapshots_exists.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_ebs_volume_snapshots_exists/ec2_ebs_volume_snapshots_exists.metadata.json
@@ -1,32 +1,38 @@
{
"Provider": "aws",
"CheckID": "ec2_ebs_volume_snapshots_exists",
- "CheckTitle": "Check if EBS snapshots exists.",
+ "CheckTitle": "EBS volume has at least one snapshot",
"CheckType": [
- "Data Protection"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Effects/Data Destruction"
],
"ServiceName": "ec2",
- "SubServiceName": "volume",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
- "Severity": "medium",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
"ResourceType": "AwsEc2Volume",
- "ResourceGroup": "storage",
- "Description": "Check if EBS snapshots exists.",
- "Risk": "Ensure that your EBS volumes (available or in-use) have recent snapshots (taken weekly) available for point-in-time recovery for a better, more reliable data backup strategy.",
- "RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSSnapshots.html",
+ "ResourceGroup": "compute",
+ "Description": "**EBS volumes** are evaluated for the existence of at least one associated **snapshot**, identifying volumes without any point-in-time backup available.",
+ "Risk": "Missing **EBS snapshots** removes point-in-time recovery. Accidental deletion, corruption, or ransomware can cause **irrecoverable data loss** and prolonged **service outages**, degrading data **integrity** and **availability** and complicating recovery and forensics.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/ebs/latest/userguide/ebs-snapshots.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/ebs-volumes-recent-snapshots.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "aws ec2 --region create-snapshot --volume-id ",
- "NativeIaC": "",
- "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/aws/EBS/ebs-volumes-recent-snapshots.html",
- "Terraform": ""
+ "CLI": "aws ec2 create-snapshot --region --volume-id ",
+ "NativeIaC": "```yaml\n# CloudFormation: create a snapshot of an existing EBS volume\nResources:\n :\n Type: AWS::EC2::Snapshot\n Properties:\n VolumeId: # Critical: creates a snapshot for this volume to pass the check\n```",
+ "Other": "1. In the AWS Console, go to EC2\n2. Click Volumes, select the target EBS volume\n3. Choose Actions > Create snapshot\n4. Click Create snapshot to confirm",
+ "Terraform": "```hcl\n# Create a snapshot for an existing EBS volume\nresource \"aws_ebs_snapshot\" \"\" {\n volume_id = \"\" # Critical: creating this snapshot makes the volume pass the check\n}\n```"
},
"Recommendation": {
- "Text": "Creating point-in-time EBS snapshots periodically will allow you to handle efficiently your data recovery process in the event of a failure, to save your data before shutting down an EC2 instance, to back up data for geographical expansion and to maintain your disaster recovery stack up to date.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSSnapshots.html"
+ "Text": "Establish automated, policy-based **EBS snapshot** coverage for all volumes aligned to business `RPO/RTO`.\n- Schedule regular snapshots with retention controls\n- Encrypt snapshots and enforce **least privilege** access\n- Replicate to another Region/account for DR\n- Periodically test restores and document procedures",
+ "Url": "https://hub.prowler.com/check/ec2_ebs_volume_snapshots_exists"
}
},
"Categories": [
+ "resilience",
"forensics-ready"
],
"DependsOn": [],
diff --git a/prowler/providers/aws/services/ec2/ec2_elastic_ip_shodan/ec2_elastic_ip_shodan.metadata.json b/prowler/providers/aws/services/ec2/ec2_elastic_ip_shodan/ec2_elastic_ip_shodan.metadata.json
index 3e21835ff1..45e3b083c3 100644
--- a/prowler/providers/aws/services/ec2/ec2_elastic_ip_shodan/ec2_elastic_ip_shodan.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_elastic_ip_shodan/ec2_elastic_ip_shodan.metadata.json
@@ -1,29 +1,34 @@
{
"Provider": "aws",
"CheckID": "ec2_elastic_ip_shodan",
- "CheckTitle": "Check if any of the Elastic or Public IP are in Shodan (requires Shodan API KEY).",
+ "CheckTitle": "EC2 Elastic IP address is not listed in Shodan",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "TTPs/Discovery"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
- "Severity": "high",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
"ResourceType": "AwsEc2Eip",
"ResourceGroup": "network",
- "Description": "Check if any of the Elastic or Public IP are in Shodan (requires Shodan API KEY).",
- "Risk": "Sites like Shodan index exposed systems and further expose them to wider audiences as a quick way to find exploitable systems.",
+ "Description": "**EC2 Elastic IPs** are compared with **Shodan**'s index to identify publicly reachable addresses that have been scanned and cataloged, including metadata such as open ports, ISP, and geolocation",
+ "Risk": "Being listed on **Shodan** confirms Internet exposure and reveals open services, versions, and banners. Adversaries can rapidly target these hosts for credential attacks and CVE exploits, threatening **confidentiality** (data access), **integrity** (system takeover), and **availability** (service disruption).",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://www.shodan.io/",
+ "https://support.icompaas.com/support/solutions/articles/62000229484-ensure-any-of-the-elastic-or-public-ip-are-in-shodan"
+ ],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
- "Other": "",
+ "Other": "1. In the AWS Console, go to EC2\n2. In the left pane, select Network & Security > Elastic IPs\n3. Select the flagged Elastic IP\n4. If it is associated: click Actions > Disassociate Elastic IP address > Disassociate\n5. Click Actions > Release Elastic IP address > Release",
"Terraform": ""
},
"Recommendation": {
- "Text": "Check Identified IPs, consider changing them to private ones and delete them from Shodan.",
- "Url": "https://www.shodan.io/"
+ "Text": "Reduce attack surface with **defense in depth**:\n- Avoid public exposure; use private networking or proxies\n- Enforce **least-privilege** ingress rules; close unused ports\n- Patch and harden services; limit verbose banners\n- Rotate exposed IPs and continuously monitor external visibility",
+ "Url": "https://hub.prowler.com/check/ec2_elastic_ip_shodan"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_elastic_ip_unassigned/ec2_elastic_ip_unassigned.metadata.json b/prowler/providers/aws/services/ec2/ec2_elastic_ip_unassigned/ec2_elastic_ip_unassigned.metadata.json
index 6c62777b0b..d94244ada3 100644
--- a/prowler/providers/aws/services/ec2/ec2_elastic_ip_unassigned/ec2_elastic_ip_unassigned.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_elastic_ip_unassigned/ec2_elastic_ip_unassigned.metadata.json
@@ -1,32 +1,38 @@
{
"Provider": "aws",
"CheckID": "ec2_elastic_ip_unassigned",
- "CheckTitle": "Check if there is any unassigned Elastic IP.",
+ "CheckTitle": "Elastic IP is associated with an instance or network interface",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Effects/Resource Consumption"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "AwsEc2Eip",
"ResourceGroup": "network",
- "Description": "Check if there is any unassigned Elastic IP.",
- "Risk": "Unassigned Elastic IPs may result in extra cost.",
+ "Description": "**EC2 Elastic IPs** that are allocated but **not associated** with any instance or network interface. The evaluation identifies EIPs present in the account without an active association.",
+ "Risk": "Unused Elastic IPs consume public IPv4 capacity and incur ongoing charges. Hoarded addresses can exhaust quotas, blocking new allocations and delaying deployments (**availability**). Lack of ownership tracking increases operational drift and misconfigurations, risking unintended exposure when later reassigned.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "aws ec2 release-address --public-ip ",
- "NativeIaC": "https://docs.prowler.com/checks/aws/general-policies/general_19#cloudformation",
- "Other": "https://docs.prowler.com/checks/aws/general-policies/general_19#ec2-console",
- "Terraform": "https://docs.prowler.com/checks/aws/general-policies/general_19#terraform"
+ "CLI": "aws ec2 release-address --allocation-id ",
+ "NativeIaC": "```yaml\n# Associate an existing unassigned Elastic IP to an instance\nResources:\n :\n Type: AWS::EC2::EIPAssociation\n Properties:\n AllocationId: # Critical: selects the unassigned EIP to associate\n InstanceId: # Critical: associates the EIP to this instance, fixing the finding\n```",
+ "Other": "1. In the AWS console, go to EC2 > Network & Security > Elastic IPs\n2. Select the Elastic IP with Status = Not associated\n3. Choose Actions > Associate Elastic IP address\n4. Select Instance (or Network interface), pick the target, and click Associate\n5. Alternatively, to remove the finding by deleting the unused EIP: Actions > Release Elastic IP address > Release",
+ "Terraform": "```hcl\n# Associate an existing unassigned Elastic IP to an instance\nresource \"aws_eip_association\" \"\" {\n allocation_id = \"\" # Critical: target unassigned EIP\n instance_id = \"\" # Critical: attach EIP to this instance to pass the check\n}\n```"
},
"Recommendation": {
- "Text": "Ensure Elastic IPs are not unassigned.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html"
+ "Text": "Release **unused Elastic IPs** or promptly associate them only where required. Enforce **least privilege** for address allocation, apply **tagging** to track ownership, and schedule periodic audits. Prefer **private networking** or managed front ends to reduce public IPv4 use. Automate reclaiming of `unassociated` addresses in lifecycle policies.",
+ "Url": "https://hub.prowler.com/check/ec2_elastic_ip_unassigned"
}
},
- "Categories": [],
+ "Categories": [
+ "internet-exposed"
+ ],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json
index 93c5c3e260..513d93ce65 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json
@@ -1,34 +1,40 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_account_imdsv2_enabled",
- "CheckTitle": "Ensure Instance Metadata Service Version 2 (IMDSv2) is enforced for EC2 instances at the account level to protect against SSRF vulnerabilities.",
+ "CheckTitle": "IMDSv2 is required by default for EC2 instances at the account level",
"CheckType": [
- "Data Protection"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Credential Access"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id",
+ "ResourceIdTemplate": "",
"Severity": "high",
- "ResourceType": "AwsAccount",
- "ResourceGroup": "governance",
- "Description": "Ensure Instance Metadata Service Version 2 (IMDSv2) is enforced for EC2 instances at the account level to protect against SSRF vulnerabilities.",
- "Risk": "EC2 instances that use IMDSv1 are vulnerable to SSRF attacks.",
- "RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html#set-imdsv2-account-defaults",
+ "ResourceType": "AwsEc2Instance",
+ "ResourceGroup": "compute",
+ "Description": "**EC2 account IMDS defaults** with `http_tokens`=`required` ensure new instances in the Region use **IMDSv2** by default and disable IMDSv1. *Existing instances keep their current setting.*",
+ "Risk": "Without a default of **IMDSv2**, new instances may enable **IMDSv1**, exposing metadata via simple HTTP. SSRF or proxy misconfigs can steal **temporary IAM credentials**, enabling data exfiltration (confidentiality), unauthorized API changes (integrity), and lateral movement that can disrupt services (availability).",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html#set-imdsv2-account-defaults",
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/require-imds-v2.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "aws ec2 modify-instance-metadata-defaults --region --http-tokens required --http-put-response-hop-limit 2",
+ "CLI": "aws ec2 modify-instance-metadata-defaults --region --http-tokens required",
"NativeIaC": "",
- "Other": "",
+ "Other": "1. In the AWS Console, open EC2 and select the target Region\n2. Go to EC2 Dashboard > Account attributes > Data protection and security\n3. Next to IMDS defaults, click Manage\n4. Set Metadata version to V2 only (token required)\n5. Click Update",
"Terraform": ""
},
"Recommendation": {
- "Text": "Enable Instance Metadata Service Version 2 (IMDSv2) on the EC2 instances. Apply this configuration at the account level for each AWS Region to set the default instance metadata version.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html#set-imdsv2-account-defaults"
+ "Text": "Enforce **IMDSv2** at the account level in every Region by setting `http_tokens` to `required`. Add guardrails with **SCP/IAM conditions**. Standardize AMIs and launch templates to require tokens, validate workload compatibility, and apply **least privilege** to instance roles for defense in depth. *For containers*, prefer hop limit `2`.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_account_imdsv2_enabled"
}
},
"Categories": [
- "internet-exposed",
- "ec2-imdsv1"
+ "secrets"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_detailed_monitoring_enabled/ec2_instance_detailed_monitoring_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_detailed_monitoring_enabled/ec2_instance_detailed_monitoring_enabled.metadata.json
index 60054d873b..90aeea9c90 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_detailed_monitoring_enabled/ec2_instance_detailed_monitoring_enabled.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_detailed_monitoring_enabled/ec2_instance_detailed_monitoring_enabled.metadata.json
@@ -1,32 +1,40 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_detailed_monitoring_enabled",
- "CheckTitle": "Check if EC2 instances have detailed monitoring enabled.",
+ "CheckTitle": "EC2 instance has detailed monitoring enabled",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Check if EC2 instances have detailed monitoring enabled.",
- "Risk": "Enabling detailed monitoring provides enhanced monitoring and granular insights into EC2 instance metrics. Not having detailed monitoring enabled may limit the ability to troubleshoot performance issues effectively.",
- "RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html",
+ "Description": "**EC2 instances** are assessed for **CloudWatch detailed monitoring**, indicating whether 1-minute metrics collection is enabled.\n\nInstances lacking this setting provide only 5-minute metrics.",
+ "Risk": "Without 1-minute metrics, visibility drops, delaying detection of:\n- Sudden CPU/network/disk spikes affecting **availability**\n- **Malicious workloads** (crypto-mining, brute force)\n- **Data exfiltration** patterns\nSlower detection expands blast radius, raising incident impact and response cost.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/instance-detailed-monitoring.html",
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html#enable-detailed-monitoring-instance"
+ ],
"Remediation": {
"Code": {
"CLI": "aws ec2 monitor-instances --instance-ids ",
- "NativeIaC": "",
- "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/instance-detailed-monitoring.html",
- "Terraform": "https://docs.prowler.com/checks/aws/logging-policies/ensure-that-detailed-monitoring-is-enabled-for-ec2-instances#terraform"
+ "NativeIaC": "```yaml\n# CloudFormation: Enable detailed monitoring on an EC2 instance\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \"\"\n InstanceType: \"\"\n Monitoring: true # Critical: enables detailed (1-minute) CloudWatch monitoring\n```",
+ "Other": "1. Open the AWS Console and go to EC2 > Instances\n2. Select the instance\n3. Choose Actions > Monitor and troubleshoot > Manage detailed monitoring\n4. Check Enable detailed monitoring and click Save",
+ "Terraform": "```hcl\n# Enable detailed monitoring on an EC2 instance\nresource \"aws_instance\" \"\" {\n ami = \"\"\n instance_type = \"\"\n monitoring = true # Critical: enables detailed (1-minute) CloudWatch monitoring\n}\n```"
},
"Recommendation": {
- "Text": "Enable detailed monitoring for EC2 instances to gain better insights into performance metrics.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html#enable-detailed-monitoring-instance"
+ "Text": "Enable **detailed monitoring** to collect `1-minute` metrics on critical instances. Use **defense in depth**: baseline normal behavior, create alerts for anomalies, and correlate metrics with logs and traces. Review dashboards regularly. *If costs matter*, prioritize production, internet-facing, and autoscaling fleets.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_detailed_monitoring_enabled"
}
},
- "Categories": [],
+ "Categories": [
+ "logging",
+ "forensics-ready"
+ ],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json
index d829d5f396..1fec3375c5 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json
@@ -1,33 +1,42 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_imdsv2_enabled",
- "CheckTitle": "Check if EC2 Instance Metadata Service Version 2 (IMDSv2) is Enabled and Required.",
+ "CheckTitle": "EC2 instance requires IMDSv2 or has the instance metadata service disabled",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "TTPs/Credential Access"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Check if EC2 Instance Metadata Service Version 2 (IMDSv2) is Enabled and Required.",
- "Risk": "Using IMDSv2 will protect from misconfiguration and SSRF vulnerabilities. IMDSv1 will not.",
+ "Description": "**EC2 instances** are evaluated for **IMDSv2 enforcement**: metadata endpoint enabled with `http_tokens: required`, or metadata service fully disabled (`http_endpoint: disabled`).",
+ "Risk": "Permitting **IMDSv1** or optional tokens lets SSRF or compromised workloads retrieve **temporary IAM credentials**, impacting confidentiality and integrity. Stolen role creds can drive **privilege escalation**, unauthorized data access, and lateral movement across AWS resources.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/require-imds-v2.html",
+ "https://support.icompaas.com/support/solutions/articles/62000234166-5-7-ensure-that-the-ec2-metadata-service-only-allows-imdsv2-automated-",
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html#configuring-instance-metadata-options"
+ ],
"Remediation": {
"Code": {
"CLI": "aws ec2 modify-instance-metadata-options --instance-id --http-tokens required --http-endpoint enabled",
- "NativeIaC": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_31#cloudformation",
- "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/require-imds-v2.html",
- "Terraform": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_31#terraform"
+ "NativeIaC": "```yaml\n# CloudFormation: enforce IMDSv2 on an EC2 instance\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \"\"\n InstanceType: \"\"\n MetadataOptions:\n HttpTokens: required # Critical: Require IMDSv2 tokens (blocks IMDSv1)\n```",
+ "Other": "1. In AWS Console, go to EC2 > Instances\n2. Select the instance > Actions > Instance settings > Modify instance metadata options\n3. Set Metadata version to IMDSv2 only (HTTP tokens: Required)\n4. Ensure Instance metadata service is Enabled (or set to Disabled to turn off IMDS entirely)\n5. Click Save",
+ "Terraform": "```hcl\n# Enforce IMDSv2 on an EC2 instance\nresource \"aws_instance\" \"\" {\n ami = \"\"\n instance_type = \"\"\n\n metadata_options {\n http_tokens = \"required\" # Critical: Require IMDSv2 tokens (blocks IMDSv1)\n }\n}\n```"
},
"Recommendation": {
- "Text": "If you don't need IMDS you can turn it off. Using aws-cli you can force the instance to use only IMDSv2.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html#configuring-instance-metadata-options"
+ "Text": "Apply defense in depth:\n- Require **IMDSv2** tokens on all instances (`http_tokens: required`)\n- Disable metadata where not needed (`http_endpoint: disabled`)\n- Minimize hop limit to `1` when feasible\n- Update SDKs/apps for IMDSv2\n- Restrict instance profile permissions (least privilege)\n- Block metadata access from untrusted workloads",
+ "Url": "https://hub.prowler.com/check/ec2_instance_imdsv2_enabled"
}
},
"Categories": [
- "ec2-imdsv1"
+ "identity-access",
+ "secrets"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_internet_facing_with_instance_profile/ec2_instance_internet_facing_with_instance_profile.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_internet_facing_with_instance_profile/ec2_instance_internet_facing_with_instance_profile.metadata.json
index 97dd166750..bd2c1d7278 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_internet_facing_with_instance_profile/ec2_instance_internet_facing_with_instance_profile.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_internet_facing_with_instance_profile/ec2_instance_internet_facing_with_instance_profile.metadata.json
@@ -1,33 +1,41 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_internet_facing_with_instance_profile",
- "CheckTitle": "Check for internet facing EC2 instances with Instance Profiles attached.",
+ "CheckTitle": "EC2 instance is not internet-facing with an instance profile attached",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "TTPs/Initial Access",
+ "TTPs/Credential Access"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
- "Severity": "medium",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Check for internet facing EC2 instances with Instance Profiles attached.",
- "Risk": "Exposing an EC2 directly to internet increases the attack surface and therefore the risk of compromise.",
+ "Description": "**EC2 instances** with a public IP address and an attached **instance profile** (IAM role) are identified.\n\nInstances lacking public exposure or without an instance profile are excluded.",
+ "Risk": "Publicly reachable instances with **IAM role credentials** expand the blast radius. Remote exploits or misconfigurations can steal credentials via the **instance metadata service**, enabling unauthorized API calls, data exfiltration, and lateral movement, impacting confidentiality, integrity, and availability.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html",
+ "https://aws.amazon.com/blogs/aws/aws-web-application-firewall-waf-for-application-load-balancers/",
+ "https://support.icompaas.com/support/solutions/articles/62000127121-ensure-instance-profile-is-attached-for-internet-facing-ec2-instances"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 disassociate-iam-instance-profile --association-id ",
+ "NativeIaC": "```yaml\n# CloudFormation: EC2 instance without a public IP to avoid being internet-facing\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: t3.micro\n NetworkInterfaces:\n - DeviceIndex: 0\n SubnetId: \n AssociatePublicIpAddress: false # Critical: disables public IPv4 so the instance is not internet-facing\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Instances and select the instance\n2. Choose Actions > Security > Modify IAM role\n3. Set IAM role to None and click Update IAM role\n4. Verify the instance no longer lists an IAM role (instance profile)\n\nAlternative (if you need the role): remove internet exposure\n1. Select the instance > Networking tab\n2. If an Elastic IP is attached, choose Disassociate Elastic IP\n3. For auto-assigned public IPv4, stop the instance and relaunch without a public IP or in a subnet without auto-assign public IPv4",
+ "Terraform": "```hcl\n# EC2 instance without a public IP to avoid being internet-facing\nresource \"aws_instance\" \"\" {\n ami = \"\"\n instance_type = \"t3.micro\"\n associate_public_ip_address = false # Critical: disables public IPv4 so the instance is not internet-facing\n}\n```"
},
"Recommendation": {
- "Text": "Use an ALB and apply WAF ACL.",
- "Url": "https://aws.amazon.com/blogs/aws/aws-web-application-firewall-waf-for-application-load-balancers/"
+ "Text": "Avoid direct Internet exposure. Place workloads behind an **Application Load Balancer** and protect HTTP apps with **WAF**. Remove public IPs or restrict ingress to trusted sources. Apply **least privilege** to instance profiles and enforce **IMDSv2**. Use **bastion hosts** or **Session Manager** for admin access.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_internet_facing_with_instance_profile"
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "identity-access"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_managed_by_ssm/ec2_instance_managed_by_ssm.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_managed_by_ssm/ec2_instance_managed_by_ssm.metadata.json
index cff3ac2e09..41f39bb5f5 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_managed_by_ssm/ec2_instance_managed_by_ssm.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_managed_by_ssm/ec2_instance_managed_by_ssm.metadata.json
@@ -1,32 +1,40 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_managed_by_ssm",
- "CheckTitle": "Check if EC2 instances are managed by Systems Manager.",
+ "CheckTitle": "EC2 instance is managed by AWS Systems Manager or not running",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Patch Management"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Check if EC2 instances are managed by Systems Manager.",
- "Risk": "AWS Config provides AWS Managed Rules, which are predefined, customizable rules that AWS Config uses to evaluate whether your AWS resource configurations comply with common best practices.",
+ "Description": "**EC2 instances** are assessed for enrollment as **Systems Manager managed nodes**. Running instances lacking Systems Manager registration are marked as unmanaged; instances in `stopped`, `terminated`, or `pending` states are noted separately.",
+ "Risk": "Unmanaged instances lack centralized patching, inventory, and secure remote access. This increases exposure to brute force on SSH/RDP, delayed patching, and poor visibility. Exploits can enable lateral movement and persistence, degrading confidentiality, integrity, and availability.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/SSM/ssm-managed-instances.html",
+ "https://docs.aws.amazon.com/systems-manager/latest/userguide/managed_instances.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/SSM/ssm-managed-instances.html",
- "Terraform": ""
+ "CLI": "aws ec2 stop-instances --instance-ids ",
+ "NativeIaC": "```yaml\n# CloudFormation: make the instance SSM-managed by attaching the required IAM role\nResources:\n Role:\n Type: AWS::IAM::Role\n Properties:\n AssumeRolePolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n Service: ec2.amazonaws.com\n Action: sts:AssumeRole\n ManagedPolicyArns:\n - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore # CRITICAL: grants SSM permissions required for management\n\n InstanceProfile:\n Type: AWS::IAM::InstanceProfile\n Properties:\n Roles:\n - !Ref Role\n\n Instance:\n Type: AWS::EC2::Instance\n Properties:\n ImageId: ami-\n InstanceType: t3.micro\n IamInstanceProfile: !Ref InstanceProfile # CRITICAL: attaches the SSM-enabled role to the instance\n```",
+ "Other": "1. In IAM console: Create role > AWS service > EC2 > Next; attach policy \"AmazonSSMManagedInstanceCore\"; Create role\n2. In EC2 console: Instances > select the instance > Actions > Security > Modify IAM role > choose the role created above > Update IAM role\n3. Wait a few minutes; in Systems Manager console: Managed nodes, verify the instance shows as Online\n4. If the instance OS does not include SSM Agent by default, install the SSM Agent for that OS, then verify again",
+ "Terraform": "```hcl\n# Terraform: make the instance SSM-managed by attaching the required IAM role\nresource \"aws_iam_role\" \"\" {\n name = \"\"\n assume_role_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Principal = { Service = \"ec2.amazonaws.com\" }\n Action = \"sts:AssumeRole\"\n }]\n })\n}\n\nresource \"aws_iam_role_policy_attachment\" \"_ssm\" {\n role = aws_iam_role..name\n policy_arn = \"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore\" # CRITICAL: grants SSM permissions required for management\n}\n\nresource \"aws_iam_instance_profile\" \"\" {\n name = \"\"\n role = aws_iam_role..name\n}\n\nresource \"aws_instance\" \"\" {\n ami = \"ami-\"\n instance_type = \"t3.micro\"\n iam_instance_profile = aws_iam_instance_profile..name # CRITICAL: attaches the SSM-enabled role to the instance\n}\n```"
},
"Recommendation": {
- "Text": "Verify and apply Systems Manager Prerequisites.",
- "Url": "https://docs.aws.amazon.com/systems-manager/latest/userguide/managed_instances.html"
+ "Text": "Enroll all instances as **Systems Manager managed nodes**. Prefer **Session Manager** over SSH/RDP, restrict inbound admin ports, and use **least privilege** roles. Ensure connectivity to SSM endpoints (or private endpoints), automate patching and inventory, and monitor activity for defense-in-depth.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_managed_by_ssm"
}
},
- "Categories": [],
+ "Categories": [
+ "node-security"
+ ],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_older_than_specific_days/ec2_instance_older_than_specific_days.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_older_than_specific_days/ec2_instance_older_than_specific_days.metadata.json
index d850e0f2c6..fb34f8b1c7 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_older_than_specific_days/ec2_instance_older_than_specific_days.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_older_than_specific_days/ec2_instance_older_than_specific_days.metadata.json
@@ -1,29 +1,34 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_older_than_specific_days",
- "CheckTitle": "Check EC2 Instances older than specific days.",
+ "CheckTitle": "EC2 instance is not older than the configured maximum age or is not running",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Patch Management"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Check EC2 Instances older than specific days.",
- "Risk": "Having old instances within your AWS account could increase the risk of having vulnerable software.",
+ "Description": "**EC2 instances** are evaluated for age while in `running` state. Instances launched beyond the configurable limit (`max_ec2_instance_age_in_days`, default `180`) are flagged as older than the allowed lifetime. Stopped instances are ignored.",
+ "Risk": "Long-lived instances often run **unpatched OS and agents**, enabling:\n- Exploitation of known CVEs loss of confidentiality\n- Privilege escalation and tampering integrity compromise\n- Malware/crypto-mining and instability reduced availability\n\nAged hosts also drift from baselines and impede response.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/systems-manager/latest/userguide/viewing-patch-compliance-results.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/ec2-instance-too-old.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
+ "CLI": "aws ec2 stop-instances --instance-ids ",
"NativeIaC": "",
- "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/ec2-instance-too-old.html",
+ "Other": "1. Sign in to the AWS Management Console and open EC2\n2. Go to Instances and select the noncompliant instance\n3. Choose Instance state > Stop instance\n4. Confirm Stop\n5. Verify the instance state is Stopped (the check passes when the instance is not running)",
"Terraform": ""
},
"Recommendation": {
- "Text": "Check if software running in the instance is up to date and patched accordingly. Use AWS Systems Manager to patch instances and view patching compliance information.",
- "Url": "https://docs.aws.amazon.com/systems-manager/latest/userguide/viewing-patch-compliance-results.html"
+ "Text": "Adopt **short-lived, patched workloads**:\n- Rebuild regularly from hardened, updated images; rotate AMIs\n- Use centralized patch management and vulnerability scanning\n- Retire or modernize legacy hosts; tag for lifecycle\n- Apply **least privilege** and **defense in depth** to limit blast radius\n\nAdjust `max_ec2_instance_age_in_days` to match policy.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_older_than_specific_days"
}
},
"Categories": [],
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_paravirtual_type/ec2_instance_paravirtual_type.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_paravirtual_type/ec2_instance_paravirtual_type.metadata.json
index 8dbe7b9788..6f8071954b 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_paravirtual_type/ec2_instance_paravirtual_type.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_paravirtual_type/ec2_instance_paravirtual_type.metadata.json
@@ -1,30 +1,39 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_paravirtual_type",
- "CheckTitle": "Amazon EC2 paravirtual virtualization type should not be used.",
- "CheckType": [],
+ "CheckTitle": "EC2 instance virtualization type is HVM",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices"
+ ],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure that the virtualization type of an EC2 instance is not paravirtual. The control fails if the virtualizationType of the EC2 instance is set to paravirtual.",
- "Risk": "Using paravirtual instances can limit performance and security benefits offered by hardware virtual machine (HVM) instances, such as improved CPU, network, and storage efficiency.",
- "RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/ec2-paravirtual-instance-check.html",
+ "Description": "**EC2 instances** are evaluated for their virtualization mode. Instances with `virtualization_type` set to `paravirtual` are identified; those using **HVM** are recognized as hardware-assisted virtualization.",
+ "Risk": "Using **paravirtual (PV)** weakens isolation versus **HVM/Nitro** and blocks features like `ENA` and `NVMe`. Confidentiality and integrity can suffer due to reliance on legacy hypercalls/drivers; availability and performance may degrade under load, increasing exposure to kernel/driver exploits and noisy-neighbor impacts.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-24",
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-resize.html",
+ "https://docs.aws.amazon.com/config/latest/developerguide/ec2-paravirtual-instance-check.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-24",
- "Terraform": ""
+ "CLI": "aws ec2 terminate-instances --instance-ids ",
+ "NativeIaC": "```yaml\n# Launch an EC2 instance using an HVM-based AMI\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: # Critical: Using an HVM AMI ensures virtualization type is HVM\n InstanceType: t3.micro\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Instances and select the instance with Virtualization type = paravirtual\n2. Launch a replacement instance using any HVM-based AMI (e.g., Amazon Linux 2)\n3. Verify the new instance is running\n4. Back in EC2 > Instances, select the paravirtual instance, choose Instance state > Terminate instance, and confirm",
+ "Terraform": "```hcl\n# EC2 instance launched from an HVM AMI\nresource \"aws_instance\" \"\" {\n ami = \"\" # Critical: AMI must be HVM to pass the check\n instance_type = \"t3.micro\"\n}\n```"
},
"Recommendation": {
- "Text": "To update an EC2 instance to a new instance type, see Change the instance type in the Amazon EC2 User Guide.",
- "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-resize.html"
+ "Text": "Standardize on **HVM/Nitro**. Migrate PV workloads to HVM AMIs and current instance families; ensure support for `ENA` and `NVMe`, current kernels, and hardened configs. Apply **defense in depth** and **least privilege**. Use immutable images with staged testing, then retire PV images to prevent drift and regressions.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_paravirtual_type"
}
},
- "Categories": [],
+ "Categories": [
+ "node-security"
+ ],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_cassandra_exposed_to_internet/ec2_instance_port_cassandra_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_cassandra_exposed_to_internet/ec2_instance_port_cassandra_exposed_to_internet.metadata.json
index 6a4161a0e5..af54b617e8 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_cassandra_exposed_to_internet/ec2_instance_port_cassandra_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_cassandra_exposed_to_internet/ec2_instance_port_cassandra_exposed_to_internet.metadata.json
@@ -1,29 +1,35 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_cassandra_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to Cassandra ports (TCP 7000, 7001, 7199, 9042, 9160).",
+ "CheckTitle": "EC2 instance does not have Cassandra ports (TCP 7000, 7001, 7199, 9042, 9160) open to the Internet",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "TTPs/Initial Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to Cassandra ports (TCP 7000, 7001, 7199, 9042, 9160).",
- "Risk": "Cassandra is a distributed database management system designed to handle large amounts of data across many commodity servers, providing high availability with no single point of failure. Exposing Cassandra ports to the internet can lead to unauthorized access to the database, data exfiltration, and data loss.",
+ "Description": "**EC2 instances** have **Cassandra service ports** (`7000`, `7001`, `7199`, `9042`, `9160`) reachable from the Internet through security group ingress.\n\nPublic IP presence and subnet exposure are considered to assess external reachability.",
+ "Risk": "Internet-exposed Cassandra enables unauthorized queries on `9042`, remote management via `7199` (JMX), and tampering with inter-node channels on `7000/7001` and `9160`.\n\nAttackers can read/modify data (**confidentiality, integrity**), disrupt or take over the cluster (**availability**), and pivot within the VPC.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://support.icompaas.com/support/solutions/articles/62000127020-ensure-security-groups-do-not-allow-unrestricted-ingress-access-to-cassandra-ports-7199-or-9160-or-88"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":7000,\"ToPort\":7000,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":7001,\"ToPort\":7001,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":7199,\"ToPort\":7199,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":9042,\"ToPort\":9042,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":9160,\"ToPort\":9160,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]]'",
+ "NativeIaC": "```yaml\n# Restrict Cassandra ports so they are not open to the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Cassandra ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 7000\n ToPort: 7000\n CidrIp: 10.0.0.0/8 # FIX: not 0.0.0.0/0; restricts Internet access\n - IpProtocol: tcp\n FromPort: 7001\n ToPort: 7001\n CidrIp: 10.0.0.0/8 # FIX\n - IpProtocol: tcp\n FromPort: 7199\n ToPort: 7199\n CidrIp: 10.0.0.0/8 # FIX\n - IpProtocol: tcp\n FromPort: 9042\n ToPort: 9042\n CidrIp: 10.0.0.0/8 # FIX\n - IpProtocol: tcp\n FromPort: 9160\n ToPort: 9160\n CidrIp: 10.0.0.0/8 # FIX\n```",
+ "Other": "1. Open the AWS Console > EC2 > Instances and select the instance\n2. In the Security tab, click the attached Security Group(s)\n3. Click Edit inbound rules\n4. Remove or change any rule allowing TCP 7000, 7001, 7199, 9042, or 9160 from Anywhere (0.0.0.0/0 or ::/0)\n5. If needed, re-add those ports with a specific trusted source CIDR or security group\n6. Save rules",
+ "Terraform": "```hcl\n# Security group with Cassandra ports not open to the Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n # FIX: restrict these ports; do not use 0.0.0.0/0\n ingress { from_port = 7000 to_port = 7000 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n ingress { from_port = 7001 to_port = 7001 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n ingress { from_port = 7199 to_port = 7199 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n ingress { from_port = 9042 to_port = 9042 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n ingress { from_port = 9160 to_port = 9160 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP ports 7000, 7001, 7199, 9042 or 9160.",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Apply **least privilege network access**:\n- Remove `0.0.0.0/0` and `::/0` to Cassandra ports\n- Allow only trusted subnets or VPN/bastion\n- Keep nodes in private subnets; segment inter-node traffic\n- Enforce **authentication** and **TLS/mTLS** for clients and JMX\n- Add **defense in depth** with NACLs and monitoring",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_cassandra_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_cifs_exposed_to_internet/ec2_instance_port_cifs_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_cifs_exposed_to_internet/ec2_instance_port_cifs_exposed_to_internet.metadata.json
index 1a013c4f8e..133c565e04 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_cifs_exposed_to_internet/ec2_instance_port_cifs_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_cifs_exposed_to_internet/ec2_instance_port_cifs_exposed_to_internet.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_cifs_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 139 or 445 (CIFS).",
+ "CheckTitle": "EC2 instance does not allow Internet ingress to TCP ports 139 or 445 (CIFS)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Initial Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 139 or 445 (CIFS).",
- "Risk": "CIFS is a file sharing protocol that is used to access files and printers on remote systems. It is not recommended to expose CIFS to the internet.",
+ "Description": "**EC2 instances** with security groups permitting **inbound** TCP `139` or `445` (**CIFS/SMB**) from `0.0.0.0/0` are identified.\n\nExposure level reflects whether the instance has a **public IP** and the subnet's Internet reachability.",
+ "Risk": "Publicly reachable **SMB** allows unauthorized access and **remote code execution**, enabling credential theft, NTLM relay, and share enumeration. Attackers can exfiltrate files, tamper or delete data, and spread **ransomware**, degrading **confidentiality**, **integrity**, and **availability**.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-cifs-access.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":139,\"ToPort\":139,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":445,\"ToPort\":445,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]}]'",
+ "NativeIaC": "```yaml\n# CloudFormation: Security group without CIFS open to the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: SG without CIFS open to Internet\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 139\n ToPort: 139\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict CIFS (139) to a non-Internet CIDR to avoid 0.0.0.0/0\n - IpProtocol: tcp\n FromPort: 445\n ToPort: 445\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict CIFS (445) to a non-Internet CIDR to avoid 0.0.0.0/0\n```",
+ "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Edit Inbound rules\n4. Delete any rule allowing TCP port 139 or 445 from 0.0.0.0/0 or ::/0, or change the source to a specific trusted CIDR\n5. Save rules",
+ "Terraform": "```hcl\n# Security group without CIFS open to the Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 139\n to_port = 139\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: restrict CIFS (139); do not use 0.0.0.0/0\n }\n\n ingress {\n from_port = 445\n to_port = 445\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: restrict CIFS (445); do not use 0.0.0.0/0\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 139 or 445 (CIFS).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Restrict **CIFS/SMB** to trusted internal sources using **least privilege**; do not allow `0.0.0.0/0`.\n\nAdopt **defense in depth**: place hosts in private subnets, require **VPN** or controlled jump paths, and enforce **segmentation**. Disable SMB if unnecessary or use alternatives (e.g., SFTP). Require strong auth and SMB signing.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_cifs_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_elasticsearch_kibana_exposed_to_internet/ec2_instance_port_elasticsearch_kibana_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_elasticsearch_kibana_exposed_to_internet/ec2_instance_port_elasticsearch_kibana_exposed_to_internet.metadata.json
index 974616bd14..a279f96116 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_elasticsearch_kibana_exposed_to_internet/ec2_instance_port_elasticsearch_kibana_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_elasticsearch_kibana_exposed_to_internet/ec2_instance_port_elasticsearch_kibana_exposed_to_internet.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_elasticsearch_kibana_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to Elasticsearch and Kibana ports (TCP 9200, 9300, 5601).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to Elasticsearch and Kibana ports (TCP 9200, 9300, 5601)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Initial Access/Unauthorized Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to Elasticsearch and Kibana ports (TCP 9200, 9300, 5601).",
- "Risk": "Elasticsearch and Kibana are commonly used for log and data analysis. Allowing ingress from the internet to these ports can expose sensitive data to unauthorized users.",
+ "Description": "**EC2 instances** with **Elasticsearch/Kibana ports** (`9200`, `9300`, `5601`) exposed to the Internet through inbound security group rules.\n\nAssesses reachability considering instance public IP and subnet to reflect real exposure.",
+ "Risk": "Public access to Elasticsearch/Kibana can lead to:\n- Unauthorized queries or dashboard viewing confidentiality loss\n- Index changes or cluster control via `9300` integrity impact\n- Scans and bulk queries availability degradation\n\nEnables data exfiltration and lateral movement.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://support.icompaas.com/support/solutions/articles/62000233821-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-elasticsearch-and-kibana-ports-tcp-9200-"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":9200,\"ToPort\":9200,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":9300,\"ToPort\":9300,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":5601,\"ToPort\":5601,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]}]'",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict Elasticsearch/Kibana ports to a private CIDR (not the Internet)\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Elasticsearch/Kibana ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 9200\n ToPort: 9200\n CidrIp: 10.0.0.0/8 # CRITICAL: do not use 0.0.0.0/0 or ::/0; restrict to internal CIDR\n - IpProtocol: tcp\n FromPort: 9300\n ToPort: 9300\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict source to stop Internet exposure\n - IpProtocol: tcp\n FromPort: 5601\n ToPort: 5601\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict source to stop Internet exposure\n```",
+ "Other": "1. Open the AWS Console and go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. In Inbound rules, find any rule allowing TCP 9200, 9300, or 5601 from 0.0.0.0/0 or ::/0\n4. Edit inbound rules and either delete those rules or change the source to a restricted CIDR (e.g., your internal network)\n5. Save rules",
+ "Terraform": "```hcl\n# Terraform: restrict Elasticsearch/Kibana ports to a private CIDR (not the Internet)\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n # CRITICAL: restrict sources; do NOT use 0.0.0.0/0 or ::/0\n ingress {\n from_port = 9200\n to_port = 9200\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n ingress {\n from_port = 9300\n to_port = 9300\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n ingress {\n from_port = 5601\n to_port = 5601\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP ports 9200, 9300, 5601.",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Apply **least privilege** to network exposure:\n- Restrict `9200`, `9300`, `5601` to trusted sources or keep them private\n- Use **private subnets**, **VPN/peering**, or **bastion/SSM** for admin access\n- Enforce **authentication** and **TLS** on Elasticsearch/Kibana\n- Avoid public IPs unless strictly required",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_elasticsearch_kibana_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_ftp_exposed_to_internet/ec2_instance_port_ftp_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_ftp_exposed_to_internet/ec2_instance_port_ftp_exposed_to_internet.metadata.json
index 1017e862d2..7254e29872 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_ftp_exposed_to_internet/ec2_instance_port_ftp_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_ftp_exposed_to_internet/ec2_instance_port_ftp_exposed_to_internet.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_ftp_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 20 or 21 (FTP)",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 20 or 21 (FTP)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Initial Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 20 or 21 (FTP).",
- "Risk": "FTP is an insecure protocol and should not be used. If FTP is required, it should be used over a secure channel such as FTPS or SFTP.",
+ "Description": "**EC2 instances** with security groups permitting inbound **FTP** on `TCP 20-21` from any address (e.g., `0.0.0.0/0` or `::/0`) are identified.\n\nExposure is contextualized by the instance's public reachability (public IP and subnet).",
+ "Risk": "Exposed **FTP** invites Internet brute force and transmits in cleartext, enabling credential theft and packet sniffing (**confidentiality**).\n\nAttackers can upload/alter files (**integrity**) and abuse services for malware staging or DoS (**availability**). Publicly reachable hosts are rapidly probed by scanners.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-ftp-access.html",
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions 'IpProtocol=tcp,FromPort=20,ToPort=21,IpRanges=[{CidrIp=0.0.0.0/0}]'",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict FTP (ports 20-21) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict FTP access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 20\n ToPort: 21\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict source; not 0.0.0.0/0. Fixes Internet exposure.\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Find any rule allowing TCP ports 20-21 from 0.0.0.0/0 or ::/0\n5. Delete the rule, or change Source to a trusted CIDR (e.g., your office IP)\n6. Click Save rules",
+ "Terraform": "```hcl\n# Security group with FTP restricted\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 20\n to_port = 21\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: restrict source; not 0.0.0.0/0. Fixes Internet exposure.\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 20 or 21 (FTP).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Deny public ingress to **FTP** ports `20-21` following **least privilege**. Prefer **SFTP** or **FTPS**; if transfers are required, restrict to trusted sources and use private access (VPN or dedicated network). Apply **defense in depth** with tightened security groups and network ACLs, and monitor authentication and access.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_ftp_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_kafka_exposed_to_internet/ec2_instance_port_kafka_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_kafka_exposed_to_internet/ec2_instance_port_kafka_exposed_to_internet.metadata.json
index cca83acff0..852d4377bf 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_kafka_exposed_to_internet/ec2_instance_port_kafka_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_kafka_exposed_to_internet/ec2_instance_port_kafka_exposed_to_internet.metadata.json
@@ -1,29 +1,37 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_kafka_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 9092 (Kafka).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 9092 (Kafka)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "TTPs/Initial Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 9092 (Kafka).",
- "Risk": "Kafka is a distributed streaming platform that is used to build real-time data pipelines and streaming applications. Exposing the Kafka port to the internet can lead to unauthorized access to the Kafka cluster, which can result in data leakage, data corruption, and data loss.",
+ "Description": "**EC2 instances** with security group rules that allow inbound `TCP 9092` (Kafka) from the Internet are reported. The evaluation inspects ingress rules to detect broad sources (for example `0.0.0.0/0` or `::/0`) that expose Kafka brokers.",
+ "Risk": "Public Kafka access undermines CIA: adversaries can read topics and metadata (**confidentiality**), publish or alter events (**integrity**), and overwhelm brokers (**availability**). Exposure also eases reconnaissance and lateral movement from the broker host.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://support.icompaas.com/support/solutions/articles/62000233794-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-9092-kafka-"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 9092 --cidr 0.0.0.0/0",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict Kafka (9092) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Kafka port\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 9092\n ToPort: 9092\n CidrIp: 10.0.0.0/8 # Critical: do NOT use 0.0.0.0/0; restrict to trusted CIDR to close Internet access\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Remove the rule allowing TCP 9092 from 0.0.0.0/0 or ::/0 (Internet)\n5. If needed, add TCP 9092 with a restricted source (e.g., your VPC CIDR)\n6. Click Save rules",
+ "Terraform": "```hcl\n# Restrict Kafka (9092) from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 9092\n to_port = 9092\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: restrict source; not 0.0.0.0/0\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 9092 (Kafka).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Apply **least privilege**: restrict `TCP 9092` to trusted networks, not `0.0.0.0/0` or `::/0`. Keep brokers in private subnets and use private connectivity (VPN/peering). Enforce **TLS** and authenticated clients with granular ACLs, and add **defense in depth** via NACLs or proxies.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_kafka_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_kerberos_exposed_to_internet/ec2_instance_port_kerberos_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_kerberos_exposed_to_internet/ec2_instance_port_kerberos_exposed_to_internet.metadata.json
index ffdb964ef7..c15d4f7eae 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_kerberos_exposed_to_internet/ec2_instance_port_kerberos_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_kerberos_exposed_to_internet/ec2_instance_port_kerberos_exposed_to_internet.metadata.json
@@ -1,29 +1,34 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_kerberos_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 88, 464, 749 or 750 (Kerberos).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 88, 464, 749, or 750 (Kerberos)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "TTPs/Initial Access/Unauthorized Access"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 88, 464, 749 or 750 (Kerberos).",
- "Risk": "Kerberos is a network authentication protocol that uses secret-key cryptography to authenticate clients and servers. It is typically used in environments where users need to authenticate to access network resources. If an EC2 instance allows ingress from the internet to TCP port 88 or 464, it may be vulnerable to unauthorized access.",
+ "Description": "**EC2 instances** whose security groups allow public **inbound TCP** access to Kerberos ports `88`, `464`, `749`, or `750` (authentication, password change, admin).\n\nRules permitting `0.0.0.0/0` or `::/0` are treated as Internet-exposed.",
+ "Risk": "Public Kerberos exposure risks CIA:\n- **Password spraying**/AS-REP roasting against accounts\n- Unauthorized password changes on `464`\n- Realm/user enumeration and DoS of KDC/services\n\nStolen tickets enable **lateral movement** and privilege escalation in Active Directory or the Kerberos realm.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://support.icompaas.com/support/solutions/articles/62000233825-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-88-464-749-or-750-kerberos-"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":88,\"ToPort\":88,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":464,\"ToPort\":464,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":749,\"ToPort\":749,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":750,\"ToPort\":750,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict Kerberos ports from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Kerberos ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 88\n ToPort: 88\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; restrict access to trusted CIDR\n - IpProtocol: tcp\n FromPort: 464\n ToPort: 464\n CidrIp: 10.0.0.0/8 # CRITICAL: blocks Internet exposure\n - IpProtocol: tcp\n FromPort: 749\n ToPort: 749\n CidrIp: 10.0.0.0/8 # CRITICAL: blocks Internet exposure\n - IpProtocol: tcp\n FromPort: 750\n ToPort: 750\n CidrIp: 10.0.0.0/8 # CRITICAL: blocks Internet exposure\n```",
+ "Other": "1. In the AWS console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Edit inbound rules\n4. Remove any rule allowing TCP ports 88, 464, 749, or 750 from 0.0.0.0/0 or ::/0\n5. If access is required, re-add these ports only from trusted CIDR(s) (e.g., your internal network)\n6. Save rules",
+ "Terraform": "```hcl\n# Terraform: restrict Kerberos ports from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 88\n to_port = 88\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; restrict to trusted CIDR\n }\n\n ingress {\n from_port = 464\n to_port = 464\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: blocks Internet exposure\n }\n\n ingress {\n from_port = 749\n to_port = 749\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: blocks Internet exposure\n }\n\n ingress {\n from_port = 750\n to_port = 750\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: blocks Internet exposure\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 88, 464, 749 or 750 (Kerberos).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Restrict Kerberos ports to trusted sources only.\n- Prefer **private connectivity** (VPN, peering) over public exposure\n- Place KDCs/services in private subnets without public IPs\n- Apply **least privilege** with narrowly scoped security group rules and NACLs\n- Add defense-in-depth: host firewalls and monitor authentication activity",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_kerberos_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_ldap_exposed_to_internet/ec2_instance_port_ldap_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_ldap_exposed_to_internet/ec2_instance_port_ldap_exposed_to_internet.metadata.json
index 564fd5153d..92cc42fefd 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_ldap_exposed_to_internet/ec2_instance_port_ldap_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_ldap_exposed_to_internet/ec2_instance_port_ldap_exposed_to_internet.metadata.json
@@ -1,29 +1,34 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_ldap_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 389 or 636 (LDAP).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 389 or 636 (LDAP/LDAPS)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Initial Access/Unauthorized Access"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 389 or 636 (LDAP).",
- "Risk": "LDAP is a protocol used for authentication and authorization. Exposing LDAP to the internet can lead to unauthorized access to the LDAP server and the data it contains.",
+ "Description": "**EC2 instances** with security groups permitting Internet-sourced access to **LDAP** on `TCP 389` or **LDAPS** on `TCP 636` are identified.\n\nPublic exposure context (presence of public IP and subnet reachability) is considered to gauge how broadly these ports can be accessed.",
+ "Risk": "Publicly reachable **LDAP/LDAPS** enables:\n- Directory enumeration and weak/anonymous bind attempts\n- **Password spraying** and credential theft (cleartext on `389`)\n- Unauthorized queries causing **data exfiltration**\n\nAbuse may lead to **privilege escalation** and availability impact via account lockouts.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":389,\"ToPort\":389,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":636,\"ToPort\":636,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]}]'",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict LDAP/LDAPS from the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restricted LDAP access\n VpcId: \"\"\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 389\n ToPort: 389\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; restricts LDAP (389) to internal range\n - IpProtocol: tcp\n FromPort: 636\n ToPort: 636\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; restricts LDAPS (636) to internal range\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. In Inbound rules, find rules for TCP 389 or 636 with Source set to Anywhere (0.0.0.0/0 or ::/0)\n4. Delete those rule(s)\n5. (If access is required) Add inbound rules for TCP 389 and/or 636 scoped to specific trusted CIDR(s) only\n6. Save rules",
+ "Terraform": "```hcl\n# Restrict LDAP/LDAPS from the Internet\nresource \"aws_security_group\" \"\" {\n name = \"restricted-ldap\"\n vpc_id = \"\"\n\n ingress {\n from_port = 389\n to_port = 389\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; restricts LDAP (389)\n }\n\n ingress {\n from_port = 636\n to_port = 636\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; restricts LDAPS (636)\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 389 or 636 (LDAP).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Limit LDAP to trusted networks:\n- Allowlist specific source CIDRs in security groups (*least privilege*)\n- Use **private connectivity** (peering/VPN) instead of Internet\n- Require **LDAPS**, strong certificates, and disable insecure binds\n- Add NACLs and monitoring for defense in depth\n\n*If external access is required*, place a proxy and enforce rate limits.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_ldap_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_memcached_exposed_to_internet/ec2_instance_port_memcached_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_memcached_exposed_to_internet/ec2_instance_port_memcached_exposed_to_internet.metadata.json
index 608ed81cc2..8ec39a3011 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_memcached_exposed_to_internet/ec2_instance_port_memcached_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_memcached_exposed_to_internet/ec2_instance_port_memcached_exposed_to_internet.metadata.json
@@ -1,29 +1,34 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_memcached_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 11211 (Memcached).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 11211 (Memcached)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "TTPs/Initial Access"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 11211 (Memcached).",
- "Risk": "Memcached is an open-source, high-performance, distributed memory object caching system. It is often used to speed up dynamic database-driven websites by caching data and objects in RAM to reduce the number of times an external data source must be read. Memcached is designed to be used in trusted environments and should not be exposed to the internet. If Memcached is exposed to the internet, it can be exploited by attackers to perform distributed denial-of-service (DDoS) attacks, data exfiltration, and other malicious activities.",
+ "Description": "**EC2 instances** are evaluated for **open Memcached access**: inbound `TCP 11211` allowed from any address (`0.0.0.0/0` or `::/0`) via their security groups, considering the instance's public exposure.",
+ "Risk": "Internet-exposed **Memcached** weakens:\n- **Availability**: abuse for reflection/amplification and resource exhaustion\n- **Confidentiality**: unauthorized reads of cached objects and metadata\n- **Integrity**: manipulation of cache entries influencing app behavior\n\nPublic reachability also aids reconnaissance and lateral movement.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://support.icompaas.com/support/solutions/articles/62000127021-ensure-security-groups-do-not-allow-unrestricted-ingress-access-to-memcached-port-11211"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 11211 --cidr 0.0.0.0/0",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict Memcached (11211) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Memcached access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 11211\n ToPort: 11211\n CidrIp: 10.0.0.0/8 # FIX: not 0.0.0.0/0; limits access to internal range to avoid Internet exposure\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. In Inbound rules, find the rule allowing TCP 11211 from 0.0.0.0/0 or ::/0\n4. Delete the rule or edit the Source to a restricted range (e.g., a private CIDR or a specific security group)\n5. Save rules",
+ "Terraform": "```hcl\n# Security group with Memcached (11211) not exposed to the Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 11211\n to_port = 11211\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # FIX: not 0.0.0.0/0; restricts access to internal range\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 11211 (Memcached).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Apply **least privilege** on network access:\n- Restrict `TCP 11211` to trusted sources or internal subnets only\n- Place instances in private subnets; avoid public IPs\n- Layer **defense in depth** with NACLs and routing to block Internet paths\n- Prefer private connectivity (peering/VPN) and implement service-level authentication where available",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_memcached_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_mongodb_exposed_to_internet/ec2_instance_port_mongodb_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_mongodb_exposed_to_internet/ec2_instance_port_mongodb_exposed_to_internet.metadata.json
index a738196e04..7c3cf7c05e 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_mongodb_exposed_to_internet/ec2_instance_port_mongodb_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_mongodb_exposed_to_internet/ec2_instance_port_mongodb_exposed_to_internet.metadata.json
@@ -1,29 +1,37 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_mongodb_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 27017 or 27018 (MongoDB)",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 27017 or 27018 (MongoDB)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "TTPs/Initial Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 27017 or 27018 (MongoDB).",
- "Risk": "MongoDB is a popular NoSQL database that is often used in web applications. If an EC2 instance allows ingress from the internet to TCP port 27017 or 27018, it may be vulnerable to unauthorized access and data exfiltration.",
+ "Description": "**EC2 instances** with security groups permitting inbound `TCP 27017` or `27018` (MongoDB) from `0.0.0.0/0` or `::/0` are identified, factoring the instance's public reachability to gauge exposure.",
+ "Risk": "Internet-exposed MongoDB invites scanning, brute force, and exploits leading to:\n- Data extraction (**confidentiality**)\n- Collection tampering or deletion (**integrity**)\n- DoS or ransomware disruptions (**availability**)\nA compromised DB host can also enable lateral movement within the environment.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://support.icompaas.com/support/solutions/articles/62000233752-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-27017-or-27018-mongodb-"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":27017,\"ToPort\":27017,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":27018,\"ToPort\":27018,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict MongoDB ports from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict MongoDB ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 27017\n ToPort: 27017\n CidrIp: 10.0.0.0/8 # Critical: restricts 27017 to internal CIDR (not 0.0.0.0/0)\n - IpProtocol: tcp\n FromPort: 27018\n ToPort: 27018\n CidrIp: 10.0.0.0/8 # Critical: restricts 27018 to internal CIDR (not 0.0.0.0/0)\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Delete any rule allowing TCP port 27017 or 27018 from 0.0.0.0/0 or ::/0\n5. If access is required, add a rule for those ports limited to a specific trusted CIDR (e.g., your VPC CIDR)\n6. Click Save rules",
+ "Terraform": "```hcl\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n # Critical: restrict MongoDB ports to internal CIDR, not 0.0.0.0/0 or ::/0\n ingress {\n from_port = 27017\n to_port = 27017\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n\n ingress {\n from_port = 27018\n to_port = 27018\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 27017 or 27018 (MongoDB).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Apply **least privilege** to MongoDB access:\n- Remove Internet-wide rules; allow only trusted sources\n- Keep DBs on **private subnets** without public IPs; use private connectivity or proxies\n- Enforce strong auth and **TLS**\n- Add segmentation and monitoring for **defense in depth**",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_mongodb_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json
index dbf2fd08fc..c67004ef3a 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_mysql_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 3306 (MySQL).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 3306 (MySQL)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Initial Access/Unauthorized Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 3306 (MySQL).",
- "Risk": "MySQL is a popular open-source relational database management system that is widely used in web applications. Exposing MySQL to the internet can lead to unauthorized access and data exfiltration.",
+ "Description": "**EC2 instances** with security groups that expose **MySQL** on `TCP 3306` to the Internet (`0.0.0.0/0` or `::/0`) are identified, with context on public IP and subnet exposure.",
+ "Risk": "Publicly reachable **MySQL** enables Internet scanning, brute force, and credential stuffing, leading to unauthorized queries and data dumps (**confidentiality**). Attackers can alter or delete data (**integrity**), overload the service with query floods (**availability**), and pivot from the DB host into adjacent workloads.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-mysql-access.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 3306 --cidr 0.0.0.0/0",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict MySQL (3306) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict MySQL access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 3306\n ToPort: 3306\n CidrIp: 10.0.0.0/8 # Critical: do NOT use 0.0.0.0/0; restrict 3306 to a private range\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Click Inbound rules > Edit inbound rules\n4. Find the rule allowing TCP 3306 from 0.0.0.0/0 or ::/0 and delete it\n5. (If access is required) Add a rule for TCP 3306 from a specific private CIDR or trusted IP range only\n6. Save rules",
+ "Terraform": "```hcl\n# Restrict MySQL (3306) from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 3306\n to_port = 3306\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: do NOT use 0.0.0.0/0; restrict 3306 to a private CIDR\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 3306 (MySQL).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Restrict `TCP 3306` to trusted sources per **least privilege**:\n- Allow DB access only from specific application subnets or security groups\n- Place database hosts in private subnets without public IPs\n- Apply **defense in depth** with VPN/peering for admin access, TLS for connections, and host firewalls; optionally reinforce with NACLs",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_mysql_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_oracle_exposed_to_internet/ec2_instance_port_oracle_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_oracle_exposed_to_internet/ec2_instance_port_oracle_exposed_to_internet.metadata.json
index 1c73fb9265..fa42c016ab 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_oracle_exposed_to_internet/ec2_instance_port_oracle_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_oracle_exposed_to_internet/ec2_instance_port_oracle_exposed_to_internet.metadata.json
@@ -1,29 +1,37 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_oracle_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 1521, 2483 or 2484 (Oracle).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 1521, 2483, or 2484 (Oracle)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "TTPs/Initial Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 1521, 2483 or 2484 (Oracle).",
- "Risk": "Oracle database servers are a high value target for attackers. Allowing internet access to these ports could lead to unauthorized access to the database.",
+ "Description": "**EC2 instances** with security groups allowing inbound `TCP` from any address to Oracle listener ports `1521`, `2483`, or `2484`",
+ "Risk": "Exposed Oracle listener ports enable SID enumeration, credential brute force, and TNS abuse. A successful intrusion can grant database access, causing data exfiltration (C), unauthorized changes (I), and outages via exploits or DoS (A). Internet scanning quickly finds these endpoints, enlarging the attack surface.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-oracle-access.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 1521 --cidr 0.0.0.0/0",
+ "NativeIaC": "```yaml\n# CloudFormation: Restrict Oracle port from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Oracle port access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 1521\n ToPort: 1521\n CidrIp: 10.0.0.0/16 # FIX: do not use 0.0.0.0/0 or ::/0; limits access to a trusted CIDR to block Internet\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. For TCP ports 1521, 2483, and 2484, delete any rule with Source 0.0.0.0/0 or ::/0\n5. If access is required, change the Source to a specific trusted CIDR only\n6. Click Save rules",
+ "Terraform": "```hcl\n# Security group with Oracle port restricted from Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 1521\n to_port = 1521\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/16\"] # FIX: restrict source; do not use 0.0.0.0/0 or ::/0\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 1521, 2483 or 2484.",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Restrict Oracle ports to trusted sources; remove `0.0.0.0/0` and `::/0`. Place databases in private subnets without public IPs. Use VPN/Direct Connect or bastions for access. Enable TLS on `2484`, strong auth, and apply **least privilege** rules with **defense in depth** using NACLs and monitoring.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_oracle_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_postgresql_exposed_to_internet/ec2_instance_port_postgresql_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_postgresql_exposed_to_internet/ec2_instance_port_postgresql_exposed_to_internet.metadata.json
index 5e6a2c09d6..8ce8482a28 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_postgresql_exposed_to_internet/ec2_instance_port_postgresql_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_postgresql_exposed_to_internet/ec2_instance_port_postgresql_exposed_to_internet.metadata.json
@@ -1,29 +1,37 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_postgresql_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 5432 (PostgreSQL)",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 5432 (PostgreSQL)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "TTPs/Initial Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 5432 (PostgreSQL).",
- "Risk": "PostgreSQL is a popular open-source relational database management system. Exposing the PostgreSQL port to the internet can lead to unauthorized access to the database, data exfiltration, and other security risks.",
+ "Description": "**EC2 instances** with security group rules allowing inbound **PostgreSQL** on `TCP 5432` from the Internet (`0.0.0.0/0` or `::/0`) are identified, considering the instance's public reachability via IP and subnet.",
+ "Risk": "Exposed `TCP 5432` enables unauthenticated Internet probes and **brute-force** attempts against PostgreSQL, risking database **confidentiality**, **integrity**, and **availability**. Attackers could dump data, alter schemas, create backdoor accounts, pivot within the VPC, or exploit unpatched flaws at scale.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-postgresql-access.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 5432 --cidr 0.0.0.0/0",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict PostgreSQL (5432) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict PostgreSQL\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 5432\n ToPort: 5432\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; limits access to internal range\n```",
+ "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Select the group attached to the instance\n3. In Inbound rules, find any rule for PostgreSQL (TCP 5432) with source 0.0.0.0/0 or ::/0\n4. Delete the rule or change the source to a specific trusted CIDR only\n5. Save rules",
+ "Terraform": "```hcl\n# Restrict PostgreSQL (5432) from Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 5432\n to_port = 5432\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; prevents Internet exposure\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 5432 (PostgreSQL).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Restrict PostgreSQL to trusted sources only:\n- Remove `0.0.0.0/0` and `::/0` rules\n- Apply **least privilege** security groups (allow from app tier or VPN)\n- Place instances in private subnets without public IPs\n- Enforce **TLS** and strong auth; disable unused listeners\n- Layer with NACLs and monitoring for **defense in depth**",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_postgresql_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_rdp_exposed_to_internet/ec2_instance_port_rdp_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_rdp_exposed_to_internet/ec2_instance_port_rdp_exposed_to_internet.metadata.json
index 92dc5338e5..2caf66efa8 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_rdp_exposed_to_internet/ec2_instance_port_rdp_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_rdp_exposed_to_internet/ec2_instance_port_rdp_exposed_to_internet.metadata.json
@@ -1,29 +1,37 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_rdp_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 3389 (RDP)",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 3389 (RDP)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "TTPs/Initial Access/External Remote Services"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 3389 (RDP).",
- "Risk": "RDP is a proprietary protocol developed by Microsoft for connecting to Windows systems. Exposing RDP to the internet can allow attackers to brute force the login credentials and gain unauthorized access to the EC2 instance.",
+ "Description": "**EC2 instances** whose security groups allow Internet-wide inbound **RDP** on `TCP 3389` (`0.0.0.0/0` or `::/0`). The instance's public IP and subnet routing are considered to determine external reachability.",
+ "Risk": "Internet-exposed **RDP** allows:\n- **Brute force** and credential reuse on Windows logons\n- Exploitation of RDP flaws for remote code execution\n- **Lateral movement** and data exfiltration\nThis threatens **confidentiality**, **integrity**, and **availability** through data theft, tampering, account lockouts, or ransomware.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://support.icompaas.com/support/solutions/articles/62000233789-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-3389-rdp-",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-rdp-access.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 3389 --cidr 0.0.0.0/0",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict RDP so it's not open to the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict RDP access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 3389\n ToPort: 3389\n CidrIp: # CRITICAL: limit RDP to a specific CIDR to avoid 0.0.0.0/0 (::/0)\n```",
+ "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Open each security group attached to the affected instance\n3. In Inbound rules, find any rule allowing TCP 3389 from 0.0.0.0/0 or ::/0\n4. Delete the rule, or edit Source to a specific trusted IP/CIDR only\n5. Click Save rules",
+ "Terraform": "```hcl\n# Restrict RDP so it's not open to the Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 3389\n to_port = 3389\n protocol = \"tcp\"\n cidr_blocks = [\"\"] # CRITICAL: restrict RDP to a specific CIDR; not 0.0.0.0/0 or ::/0\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 3389 (RDP).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Remove Internet-wide RDP. Apply **least privilege**:\n- Restrict `TCP 3389` to trusted IPs\n- Prefer private access via **VPN** or a hardened **bastion**; consider **Session Manager**\n- Use **just-in-time** access and short-lived rules\n- Enforce strong auth (e.g., NLA) and monitor logs\nAdopt **defense in depth** with layered network controls.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_rdp_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_redis_exposed_to_internet/ec2_instance_port_redis_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_redis_exposed_to_internet/ec2_instance_port_redis_exposed_to_internet.metadata.json
index 10451778c6..d37769600e 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_redis_exposed_to_internet/ec2_instance_port_redis_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_redis_exposed_to_internet/ec2_instance_port_redis_exposed_to_internet.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_redis_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 6379 (Redis).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 6379 (Redis)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "TTPs/Initial Access/Unauthorized Access",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 6379 (Redis).",
- "Risk": "Redis is an open-source, in-memory data structure store, used as a database, cache, and message broker. Redis is often used to store sensitive data, such as session tokens, user credentials, and other sensitive information. Allowing ingress from the internet to TCP port 6379 (Redis) can expose sensitive data to unauthorized users.",
+ "Description": "**EC2 instances** with security groups permitting Internet access to **Redis** on `TCP 6379` are identified.\n\nExposure is assessed using public IP assignment and subnet reachability to reflect how broadly the service can be contacted.",
+ "Risk": "Exposed **Redis** allows remote access to cached data and secrets, reducing **confidentiality**. Unauthorized commands (`SET`, `DEL`, `FLUSHALL`, config changes) can corrupt or erase data, harming **integrity**. Internet scanning and abuse can exhaust memory and disrupt service, degrading **availability** and enabling lateral movement.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://support.icompaas.com/support/solutions/articles/62000233806-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-6379-redis-",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-redis-access.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 6379 --cidr 0.0.0.0/0",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict Redis (6379) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Redis ingress\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 6379\n ToPort: 6379\n CidrIp: 10.0.0.0/8 # Critical: restrict source CIDR (not 0.0.0.0/0 or ::/0) to block Internet access\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Find any rule allowing TCP port 6379 from 0.0.0.0/0 or ::/0\n5. Delete the rule, or change the source to a specific trusted CIDR or security group\n6. Click Save rules",
+ "Terraform": "```hcl\n# Terraform: restrict Redis (6379) from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 6379\n to_port = 6379\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: not 0.0.0.0/0 or ::/0; restrict to trusted range\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 6379 (Redis).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Apply **least privilege** network access: restrict Redis to trusted sources or VPC-only, place instances in private subnets, and avoid public IPs.\n\nLayer controls with **NACLs** and host firewalls, enforce **authentication and TLS** on Redis, and use **VPN/bastion** or proxies to broker access.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_redis_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_sqlserver_exposed_to_internet/ec2_instance_port_sqlserver_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_sqlserver_exposed_to_internet/ec2_instance_port_sqlserver_exposed_to_internet.metadata.json
index 26c6be9f99..a23441b72b 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_sqlserver_exposed_to_internet/ec2_instance_port_sqlserver_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_sqlserver_exposed_to_internet/ec2_instance_port_sqlserver_exposed_to_internet.metadata.json
@@ -1,29 +1,37 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_sqlserver_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 1433 or 1434 (SQL Server).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 1433 or 1434 (SQL Server)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "TTPs/Initial Access"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 1433 or 1434 (SQL Server).",
- "Risk": "SQL Server is a database management system that is used to store and retrieve data. If an EC2 instance allows ingress from the internet to TCP port 1433 or 1434, it may be vulnerable to unauthorized access and data exfiltration.",
+ "Description": "**EC2 instances** with security groups permitting any source to `TCP 1433` or `1434` (SQL Server) are identified, considering the instance's public reachability based on IP and subnet exposure.",
+ "Risk": "Internet-reachable SQL services enable:\n- Brute-force and credential-stuffing of DB logins\n- Exploitation of SQL Server flaws for remote code execution\n- Unauthorized queries and data exfiltration\nThis threatens **confidentiality** and **integrity**, and facilitates **lateral movement** from the database host.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://support.icompaas.com/support/solutions/articles/62000223371-ensure-no-security-groups-allow-ingress-from-0-0-0-0-0-or-0-to-windows-sql-server-ports-1433-or-14",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-mssql-access.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":1433,\"ToPort\":1434,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict SQL Server ports to non-Internet sources\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict SQL ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 1433\n ToPort: 1434\n CidrIp: 10.0.0.0/8 # FIX: do not use 0.0.0.0/0; limits access to internal range to close Internet exposure\n```",
+ "Other": "1. In the AWS Console, go to VPC > Security Groups\n2. Select the security group attached to the affected EC2 instance\n3. In the Inbound rules tab, click Edit inbound rules\n4. Delete any rule allowing TCP 1433 or 1434 from 0.0.0.0/0 or ::/0\n5. If access is required, add a rule for TCP 1433-1434 with a specific trusted source (e.g., your office IP or another security group)\n6. Click Save rules",
+ "Terraform": "```hcl\n# Restrict SQL Server ports to non-Internet sources\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 1433\n to_port = 1434\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # FIX: do not use 0.0.0.0/0; restricts access to prevent Internet exposure\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 1433 or 1434 (SQL Server).",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Enforce **least privilege** and **defense in depth**:\n- Remove `0.0.0.0/0` and `::/0` to `1433-1434`\n- Allow only trusted IPs or app tiers via security group references\n- Keep databases in private subnets without public IPs; access via VPN or bastion\n- Require TLS and strong authentication; monitor access.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_sqlserver_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_ssh_exposed_to_internet/ec2_instance_port_ssh_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_ssh_exposed_to_internet/ec2_instance_port_ssh_exposed_to_internet.metadata.json
index 2fbce38d91..727e089d00 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_ssh_exposed_to_internet/ec2_instance_port_ssh_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_ssh_exposed_to_internet/ec2_instance_port_ssh_exposed_to_internet.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_ssh_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 22 (SSH)",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 22 (SSH)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark",
+ "TTPs/Initial Access/External Remote Services"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 22 (SSH).",
- "Risk": "SSH is a common target for brute force attacks. If an EC2 instance allows ingress from the internet to TCP port 22, it is at risk of being compromised.",
+ "Description": "**EC2 instances** with **SSH (TCP 22)** exposed to the Internet via security group inbound rules allowing `0.0.0.0/0` or `::/0`.\n\nExposure is qualified using the instance's public IP status and subnet reachability.",
+ "Risk": "**Internet-exposed SSH** invites **brute force** and **credential stuffing**. A successful sign-in grants **remote shell**, enabling data exfiltration, tampering of workloads, and **lateral movement** within the VPC, degrading confidentiality, integrity, and availability.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-ssh-access.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":22,\"ToPort\":22,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":22,\"ToPort\":22,\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'",
+ "NativeIaC": "```yaml\n# CloudFormation: Restrict SSH so it's not open to the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict SSH\n VpcId: \"\"\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 22\n ToPort: 22\n CidrIp: 10.0.0.0/8 # Critical: restrict SSH to a specific CIDR, not 0.0.0.0/0\n```",
+ "Other": "1. Open the Amazon EC2 console and go to Security Groups\n2. Select the security group attached to the instance\n3. Click Inbound rules > Edit inbound rules\n4. Delete any rule allowing SSH (port 22) from 0.0.0.0/0 or ::/0\n5. Save rules",
+ "Terraform": "```hcl\n# Terraform: Restrict SSH so it's not open to the Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 22\n to_port = 22\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: restrict SSH to a specific CIDR, not 0.0.0.0/0\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 22.",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Apply **least privilege** on SSH:\n- Restrict ingress to trusted IPs; avoid `0.0.0.0/0` and `::/0`\n- Prefer **Session Manager** or a hardened **bastion** behind VPN\n- Use **key-based auth**; disable passwords\n- Add **defense in depth** with network controls and monitor access logs",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_ssh_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_telnet_exposed_to_internet/ec2_instance_port_telnet_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_telnet_exposed_to_internet/ec2_instance_port_telnet_exposed_to_internet.metadata.json
index 371501b8ca..733c055fea 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_port_telnet_exposed_to_internet/ec2_instance_port_telnet_exposed_to_internet.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_port_telnet_exposed_to_internet/ec2_instance_port_telnet_exposed_to_internet.metadata.json
@@ -1,29 +1,35 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_port_telnet_exposed_to_internet",
- "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 23 (Telnet).",
+ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 23 (Telnet)",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Initial Access/Unauthorized Access"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 23 (Telnet).",
- "Risk": "Telnet is an insecure protocol that transmits data in plain text. Exposure of Telnet services to the internet can lead to unauthorized access to the EC2 instance.",
+ "Description": "EC2 instances with security groups allowing inbound **Telnet** on `TCP 23` from the Internet are identified, including open IPv4/IPv6 sources like `0.0.0.0/0` and `::/0`.\n\nExposure is evaluated considering public IP assignment and subnet reachability.",
+ "Risk": "Exposed **Telnet** weakens **confidentiality** and **integrity**: credentials and commands are plaintext, enabling interception and session hijacking. Attackers can brute-force to gain shell, run remote commands, exfiltrate data, and pivot laterally, also threatening **availability** through misuse or takeover.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-telnet-access.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "",
- "Terraform": ""
+ "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 23 --cidr 0.0.0.0/0",
+ "NativeIaC": "```yaml\n# CloudFormation: restrict Telnet (port 23) from the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Telnet access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 23\n ToPort: 23\n CidrIp: 10.0.0.0/8 # Critical: do NOT use 0.0.0.0/0; restrict Telnet to trusted CIDR to remediate exposure\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Open the Inbound rules tab and find any rule allowing TCP port 23 from 0.0.0.0/0 or ::/0\n4. Delete the rule, or edit it to a specific trusted CIDR only\n5. Click Save rules",
+ "Terraform": "```hcl\n# Restrict Telnet (port 23) from the Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 23\n to_port = 23\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: do NOT use 0.0.0.0/0; restrict Telnet to trusted CIDR to fix the finding\n }\n}\n```"
},
"Recommendation": {
- "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 23.",
- "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html"
+ "Text": "Eliminate Telnet: disable the service and block `TCP 23`.\n\nApply **least privilege** network access-restrict admin connectivity via **SSH** through bastion or **VPN**, keep management paths private, and segregate hosts. Use **defense in depth** with monitoring and strong authentication for any legacy needs.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_port_telnet_exposed_to_internet"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_profile_attached/ec2_instance_profile_attached.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_profile_attached/ec2_instance_profile_attached.metadata.json
index 43dfe535ea..6cc1b53430 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_profile_attached/ec2_instance_profile_attached.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_profile_attached/ec2_instance_profile_attached.metadata.json
@@ -1,32 +1,39 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_profile_attached",
- "CheckTitle": "Ensure IAM instance roles are used for AWS resource access from instances",
+ "CheckTitle": "EC2 instance is associated with an IAM instance profile role",
"CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Ensure IAM instance roles are used for AWS resource access from instances.",
- "Risk": "AWS access from within AWS instances can be done by either encoding AWS keys into AWS API calls or by assigning the instance to a role which has an appropriate permissions policy for the required access. AWS IAM roles reduce the risks associated with sharing and rotating credentials that can be used outside of AWS itself. If credentials are compromised, they can be used from outside of the AWS account.",
+ "Description": "**EC2 instances** are evaluated for association with an **IAM instance profile role** that delivers temporary credentials to workloads running on the instance",
+ "Risk": "Without an instance profile, apps often rely on long-term access keys on the host. Exposed keys can be used from anywhere to read data, alter resources, or disrupt services, impacting confidentiality, integrity, and availability. Keys may persist in AMIs, images, or logs, hindering rotation and amplifying blast radius.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "http://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html",
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/ec2-instance-using-iam-roles.html"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "https://github.com/cloudmatos/matos/tree/master/remediations/aws/ec2/attach_iam_roles_ec2_instances",
- "Terraform": ""
+ "CLI": "aws ec2 associate-iam-instance-profile --instance-id --iam-instance-profile Name=",
+ "NativeIaC": "```yaml\n# CloudFormation: attach an IAM instance profile to the EC2 instance\nResources:\n ExampleInstance:\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: \n IamInstanceProfile: # Critical: associates an instance profile so the check passes\n```",
+ "Other": "1. Open the AWS Management Console and go to EC2\n2. Select Instances and choose the target instance\n3. Click Actions > Security > Modify IAM role\n4. Select the IAM role (instance profile) to attach\n5. Click Update IAM role",
+ "Terraform": "```hcl\n# Attach an IAM instance profile to the EC2 instance\nresource \"aws_instance\" \"example\" {\n ami = \"\"\n instance_type = \"\"\n iam_instance_profile = \"\" # Critical: associates an instance profile so the check passes\n}\n```"
},
"Recommendation": {
- "Text": "Create an IAM instance role if necessary and attach it to the corresponding EC2 instance..",
- "Url": "http://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html"
+ "Text": "Attach an **IAM instance profile** to every instance and grant only permissions each workload requires (**least privilege**). Eliminate static keys on hosts; use **temporary credentials** with automatic rotation. Separate roles per application, enforce **separation of duties**, and limit who can assign roles (govern via `iam:PassRole`). Monitor role usage for anomalies.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_profile_attached"
}
},
- "Categories": [],
+ "Categories": [
+ "identity-access"
+ ],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.metadata.json
index f835b8ae7b..509b7394c7 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.metadata.json
@@ -1,29 +1,35 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_public_ip",
- "CheckTitle": "Check for EC2 Instances with Public IP.",
+ "CheckTitle": "EC2 instance does not have a public IP address",
"CheckType": [
- "Infrastructure Security"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Initial Access"
],
"ServiceName": "ec2",
- "SubServiceName": "instance",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Check for EC2 Instances with Public IP.",
- "Risk": "Exposing an EC2 directly to internet increases the attack surface and therefore the risk of compromise.",
+ "Description": "**EC2 instances** are assessed for the presence of a **public IPv4 address** and public DNS. A public IP indicates the instance is directly reachable from the Internet; no public IP implies access only through private networking paths such as load balancers, gateways, or proxies.",
+ "Risk": "Publicly addressed instances are Internet-scannable, enabling direct probing and brute-force of exposed services and management ports. This increases risks of unauthorized access, remote code execution, and data exfiltration (**confidentiality, integrity**), and allows direct DDoS targeting, degrading **availability**.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/aws-ec2-public-ip.html",
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-instance-addressing.html"
+ ],
"Remediation": {
"Code": {
"CLI": "",
- "NativeIaC": "https://docs.prowler.com/checks/aws/public-policies/public_12#cloudformation",
- "Other": "https://docs.prowler.com/checks/aws/public-policies/public_12#aws-console",
- "Terraform": "https://docs.prowler.com/checks/aws/public-policies/public_12#terraform"
+ "NativeIaC": "```yaml\n# CloudFormation: Launch EC2 without a public IP\nResources:\n ExampleInstance:\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: \n NetworkInterfaces:\n - DeviceIndex: 0\n SubnetId: \n AssociatePublicIpAddress: false # CRITICAL: ensures the instance has no public IPv4 address\n```",
+ "Other": "1. In the AWS Console, go to EC2 > Instances and select the instance with a public IPv4 address\n2. Check the Networking tab to see if an Elastic IP is attached\n3. If an Elastic IP is attached:\n - Go to EC2 > Elastic IPs, select the address, choose Actions > Disassociate Elastic IP\n4. If the public IPv4 is auto-assigned (no Elastic IP shown):\n - Create a new instance (or an AMI from the current one) and, during launch, in Network settings, set Auto-assign public IP to Disable\n - Verify the new instance has no public IPv4, then migrate and terminate the old instance",
+ "Terraform": "```hcl\n# Terraform: Launch EC2 without a public IP\nresource \"aws_instance\" \"example\" {\n ami = \"\"\n instance_type = \"\"\n subnet_id = \"\"\n\n associate_public_ip_address = false # CRITICAL: ensures no public IPv4 is assigned\n}\n```"
},
"Recommendation": {
- "Text": "Use an ALB and apply WAF ACL.",
- "Url": "https://aws.amazon.com/blogs/aws/aws-web-application-firewall-waf-for-application-load-balancers/"
+ "Text": "Avoid assigning public IPs unless strictly required. Place workloads in private subnets and expose only via **load balancers** with **WAF**; use **bastions** or **Session Manager** for administration. Enforce **least privilege** security groups, prefer **private endpoints**, and route egress via **NAT** for **defense in depth**.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_public_ip"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.metadata.json
index 2adc3bb922..455bfce9f7 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.metadata.json
@@ -1,29 +1,36 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_secrets_user_data",
- "CheckTitle": "Find secrets in EC2 User Data.",
+ "CheckTitle": "EC2 instance user data contains no secrets",
"CheckType": [
- "IAM"
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Sensitive Data Identifications/Security",
+ "Sensitive Data Identifications/Passwords",
+ "Effects/Data Exposure"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:access-analyzer:region:account-id:analyzer/resource-id",
- "Severity": "critical",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "Find secrets in EC2 User Data.",
- "Risk": "Secrets hardcoded into instance user data can be used by malware and bad actors to gain lateral access to other services.",
+ "Description": "**EC2 instance User Data** is inspected for **secret-like values** (credentials, tokens, keys). Both plain and compressed content are parsed, honoring configured exclusions, to identify patterns that resemble sensitive material within initialization scripts.",
+ "Risk": "**Secrets embedded in User Data** undermine confidentiality and integrity. Anyone with instance or build-system access can read them, reuse credentials to call services, exfiltrate data, or move laterally. Exposure may persist in AMIs, snapshots, and backups, increasing blast radius over time.",
"RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/secretsmanager/latest/userguide/tutorials_basic.html",
+ "https://support.icompaas.com/support/solutions/articles/62000127092-ensure-no-secrets-are-found-in-ec2-user-data"
+ ],
"Remediation": {
"Code": {
- "CLI": "aws ec2 describe-instance-attribute --attribute userData --region --instance-id --query UserData.Value --output text > encodeddata; base64 --decode encodeddata",
- "NativeIaC": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_1#cloudformation",
- "Other": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_1",
- "Terraform": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_1#terraform"
+ "CLI": "aws ec2 modify-instance-attribute --instance-id --user-data \"Value=\"",
+ "NativeIaC": "```yaml\n# CloudFormation: EC2 instance with empty user data\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: t3.micro\n UserData: !Base64 \"\" # Critical: empty user data ensures no secrets are present\n```",
+ "Other": "1. Open the AWS EC2 console and go to Instances\n2. Select the affected instance\n3. Click Actions > Instance settings > Edit user data\n4. Delete all contents of the user data field\n5. Click Save",
+ "Terraform": "```hcl\nresource \"aws_instance\" \"\" {\n ami = \"\"\n instance_type = \"t3.micro\"\n user_data = \"\" # Critical: empty user data so no secrets are stored\n}\n```"
},
"Recommendation": {
- "Text": "Implement automated detective control (e.g. using tools like Prowler) to scan accounts for passwords and secrets. Use secrets manager service to store and retrieve passwords and secrets.",
- "Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/tutorials_basic.html"
+ "Text": "Avoid placing secrets in User Data. Store them in a **managed secret service** and fetch at runtime via a **least-privilege instance role**. Prefer short-lived credentials with **regular rotation**. Limit who can view or edit User Data and apply **defense in depth** with automated secret scanning in build pipelines.",
+ "Url": "https://hub.prowler.com/check/ec2_instance_secrets_user_data"
}
},
"Categories": [
diff --git a/prowler/providers/aws/services/ec2/ec2_instance_uses_single_eni/ec2_instance_uses_single_eni.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_uses_single_eni/ec2_instance_uses_single_eni.metadata.json
index edc64e0fcf..60fa42cf35 100644
--- a/prowler/providers/aws/services/ec2/ec2_instance_uses_single_eni/ec2_instance_uses_single_eni.metadata.json
+++ b/prowler/providers/aws/services/ec2/ec2_instance_uses_single_eni/ec2_instance_uses_single_eni.metadata.json
@@ -1,29 +1,34 @@
{
"Provider": "aws",
"CheckID": "ec2_instance_uses_single_eni",
- "CheckTitle": "Amazon EC2 instances should not use multiple ENIs",
+ "CheckTitle": "EC2 instance has no more than one Elastic Network Interface (ENI) attached",
"CheckType": [
- "Software and Configuration Checks/AWS Security Best Practices"
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability"
],
"ServiceName": "ec2",
"SubServiceName": "",
- "ResourceIdTemplate": "arn:partition:service:region:account-id:instance/resource-id",
+ "ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "AwsEc2Instance",
"ResourceGroup": "compute",
- "Description": "This control checks whether an EC2 instance uses multiple Elastic Network Interfaces (ENIs) or Elastic Fabric Adapters (EFAs). This control passes if a single network adapter is used. The control includes an optional parameter list to identify the allowed ENIs. This control also fails if an EC2 instance that belongs to an Amazon EKS cluster uses more than one ENI. If your EC2 instances need to have multiple ENIs as part of an Amazon EKS cluster, you can suppress those control findings.",
- "Risk": "Multiple ENIs can cause dual-homed instances, meaning instances that have multiple subnets. This can add network security complexity and introduce unintended network paths and access.",
- "RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/ec2-instance-multiple-eni-check.html",
+ "Description": "**EC2 instances** are evaluated for attached network adapters. It identifies instances with more than one `ENI`-including `efa`, `interface`, or `trunk` types-and distinguishes those using a single adapter.",
+ "Risk": "**Multiple ENIs** create dual-homed hosts across subnets and security groups, enabling unintended routing and policy bypass. Adversaries can pivot between segments, use alternate egress for **data exfiltration**, or exploit asymmetric paths, undermining segmentation and **confidentiality/integrity** while complicating containment.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-eni.html#detach_eni",
+ "https://docs.aws.amazon.com/config/latest/developerguide/ec2-instance-multiple-eni-check.html",
+ "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-17"
+ ],
"Remediation": {
"Code": {
- "CLI": "",
- "NativeIaC": "",
- "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-17",
- "Terraform": ""
+ "CLI": "aws ec2 detach-network-interface --attachment-id ",
+ "NativeIaC": "```yaml\n# CloudFormation: ensure the instance has only one ENI\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: t3.micro\n SubnetId: # FIX: creates a single primary ENI; do not add extra NetworkInterfaces/attachments\n```",
+ "Other": "1. Open the AWS EC2 console and go to Network Interfaces\n2. Filter by the affected instance ID\n3. Select each non-primary network interface (Primary cannot be detached)\n4. Choose Actions > Detach\n5. Confirm the detach for each secondary ENI",
+ "Terraform": "```hcl\n# Terraform: instance with only the primary ENI\nresource \"aws_instance\" \"\" {\n ami = \"